diff --git a/NOTICE.txt b/NOTICE.txt index 946b328b8766c1..94312d46c35ecb 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -29,6 +29,68 @@ Author Tobias Koppers @sokra --- This product has relied on ASTExplorer that is licensed under MIT. +--- +This product includes code that is based on Ace editor, which was available +under a "BSD" license. + +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + + Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- +This product includes code that is based on Ace editor, which was available +under a "BSD" license. + +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + --- This product includes code that is based on flot-charts, which was available under a "MIT" license. diff --git a/docs/development/core/server/kibana-plugin-core-server.corestart.http.md b/docs/development/core/server/kibana-plugin-core-server.corestart.http.md new file mode 100644 index 00000000000000..d81049dfbd340f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.corestart.http.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreStart](./kibana-plugin-core-server.corestart.md) > [http](./kibana-plugin-core-server.corestart.http.md) + +## CoreStart.http property + +[HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) + +Signature: + +```typescript +http: HttpServiceStart; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.corestart.md b/docs/development/core/server/kibana-plugin-core-server.corestart.md index c50e8924c9dd40..6a6bacf1eef40b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.corestart.md +++ b/docs/development/core/server/kibana-plugin-core-server.corestart.md @@ -18,6 +18,7 @@ export interface CoreStart | --- | --- | --- | | [capabilities](./kibana-plugin-core-server.corestart.capabilities.md) | CapabilitiesStart | [CapabilitiesStart](./kibana-plugin-core-server.capabilitiesstart.md) | | [elasticsearch](./kibana-plugin-core-server.corestart.elasticsearch.md) | ElasticsearchServiceStart | [ElasticsearchServiceStart](./kibana-plugin-core-server.elasticsearchservicestart.md) | +| [http](./kibana-plugin-core-server.corestart.http.md) | HttpServiceStart | [HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) | | [savedObjects](./kibana-plugin-core-server.corestart.savedobjects.md) | SavedObjectsServiceStart | [SavedObjectsServiceStart](./kibana-plugin-core-server.savedobjectsservicestart.md) | | [uiSettings](./kibana-plugin-core-server.corestart.uisettings.md) | UiSettingsServiceStart | [UiSettingsServiceStart](./kibana-plugin-core-server.uisettingsservicestart.md) | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpauth.get.md b/docs/development/core/server/kibana-plugin-core-server.httpauth.get.md new file mode 100644 index 00000000000000..4ea67cf895a27e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpauth.get.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpAuth](./kibana-plugin-core-server.httpauth.md) > [get](./kibana-plugin-core-server.httpauth.get.md) + +## HttpAuth.get property + +Gets authentication state for a request. Returned by `auth` interceptor. [GetAuthState](./kibana-plugin-core-server.getauthstate.md) + +Signature: + +```typescript +get: GetAuthState; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpauth.isauthenticated.md b/docs/development/core/server/kibana-plugin-core-server.httpauth.isauthenticated.md new file mode 100644 index 00000000000000..54db6bce5f1614 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpauth.isauthenticated.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpAuth](./kibana-plugin-core-server.httpauth.md) > [isAuthenticated](./kibana-plugin-core-server.httpauth.isauthenticated.md) + +## HttpAuth.isAuthenticated property + +Returns authentication status for a request. [IsAuthenticated](./kibana-plugin-core-server.isauthenticated.md) + +Signature: + +```typescript +isAuthenticated: IsAuthenticated; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpauth.md b/docs/development/core/server/kibana-plugin-core-server.httpauth.md new file mode 100644 index 00000000000000..d9d77809570abd --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpauth.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpAuth](./kibana-plugin-core-server.httpauth.md) + +## HttpAuth interface + + +Signature: + +```typescript +export interface HttpAuth +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [get](./kibana-plugin-core-server.httpauth.get.md) | GetAuthState | Gets authentication state for a request. Returned by auth interceptor. [GetAuthState](./kibana-plugin-core-server.getauthstate.md) | +| [isAuthenticated](./kibana-plugin-core-server.httpauth.isauthenticated.md) | IsAuthenticated | Returns authentication status for a request. [IsAuthenticated](./kibana-plugin-core-server.isauthenticated.md) | + diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.auth.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.auth.md index 6667779c1c7aea..da348a2282b1a0 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.auth.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.auth.md @@ -4,11 +4,15 @@ ## HttpServiceSetup.auth property +> Warning: This API is now obsolete. +> +> use [the start contract](./kibana-plugin-core-server.httpservicestart.auth.md) instead. +> + +Auth status. See [HttpAuth](./kibana-plugin-core-server.httpauth.md) + Signature: ```typescript -auth: { - get: GetAuthState; - isAuthenticated: IsAuthenticated; - }; +auth: HttpAuth; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.istlsenabled.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.istlsenabled.md deleted file mode 100644 index fa86da18393f59..00000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.istlsenabled.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) > [isTlsEnabled](./kibana-plugin-core-server.httpservicesetup.istlsenabled.md) - -## HttpServiceSetup.isTlsEnabled property - -Flag showing whether a server was configured to use TLS connection. - -Signature: - -```typescript -isTlsEnabled: boolean; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md index 2dd832813afb82..b12983836d9e5a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md @@ -81,13 +81,12 @@ async (context, request, response) => { | Property | Type | Description | | --- | --- | --- | -| [auth](./kibana-plugin-core-server.httpservicesetup.auth.md) | {
get: GetAuthState;
isAuthenticated: IsAuthenticated;
} | | +| [auth](./kibana-plugin-core-server.httpservicesetup.auth.md) | HttpAuth | Auth status. See [HttpAuth](./kibana-plugin-core-server.httpauth.md) | | [basePath](./kibana-plugin-core-server.httpservicesetup.basepath.md) | IBasePath | Access or manipulate the Kibana base path See [IBasePath](./kibana-plugin-core-server.ibasepath.md). | | [createCookieSessionStorageFactory](./kibana-plugin-core-server.httpservicesetup.createcookiesessionstoragefactory.md) | <T>(cookieOptions: SessionStorageCookieOptions<T>) => Promise<SessionStorageFactory<T>> | Creates cookie based session storage factory [SessionStorageFactory](./kibana-plugin-core-server.sessionstoragefactory.md) | | [createRouter](./kibana-plugin-core-server.httpservicesetup.createrouter.md) | () => IRouter | Provides ability to declare a handler function for a particular path and HTTP request method. | | [csp](./kibana-plugin-core-server.httpservicesetup.csp.md) | ICspConfig | The CSP config used for Kibana. | | [getServerInfo](./kibana-plugin-core-server.httpservicesetup.getserverinfo.md) | () => HttpServerInfo | Provides common [information](./kibana-plugin-core-server.httpserverinfo.md) about the running http server. | -| [isTlsEnabled](./kibana-plugin-core-server.httpservicesetup.istlsenabled.md) | boolean | Flag showing whether a server was configured to use TLS connection. | | [registerAuth](./kibana-plugin-core-server.httpservicesetup.registerauth.md) | (handler: AuthenticationHandler) => void | To define custom authentication and/or authorization mechanism for incoming requests. | | [registerOnPostAuth](./kibana-plugin-core-server.httpservicesetup.registeronpostauth.md) | (handler: OnPostAuthHandler) => void | To define custom logic to perform for incoming requests. | | [registerOnPreAuth](./kibana-plugin-core-server.httpservicesetup.registeronpreauth.md) | (handler: OnPreAuthHandler) => void | To define custom logic to perform for incoming requests. | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicestart.islistening.md b/docs/development/core/server/kibana-plugin-core-server.httpservicestart.auth.md similarity index 50% rename from docs/development/core/server/kibana-plugin-core-server.httpservicestart.islistening.md rename to docs/development/core/server/kibana-plugin-core-server.httpservicestart.auth.md index bf2922c62c15f0..f7dffee2e125ca 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicestart.islistening.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicestart.auth.md @@ -1,13 +1,13 @@ -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) > [isListening](./kibana-plugin-core-server.httpservicestart.islistening.md) +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) > [auth](./kibana-plugin-core-server.httpservicestart.auth.md) -## HttpServiceStart.isListening property +## HttpServiceStart.auth property -Indicates if http server is listening on a given port +Auth status. See [HttpAuth](./kibana-plugin-core-server.httpauth.md) Signature: ```typescript -isListening: (port: number) => boolean; +auth: HttpAuth; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicestart.basepath.md b/docs/development/core/server/kibana-plugin-core-server.httpservicestart.basepath.md new file mode 100644 index 00000000000000..e8b2a0fc2cbaac --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicestart.basepath.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) > [basePath](./kibana-plugin-core-server.httpservicestart.basepath.md) + +## HttpServiceStart.basePath property + +Access or manipulate the Kibana base path See [IBasePath](./kibana-plugin-core-server.ibasepath.md). + +Signature: + +```typescript +basePath: IBasePath; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicestart.getserverinfo.md b/docs/development/core/server/kibana-plugin-core-server.httpservicestart.getserverinfo.md new file mode 100644 index 00000000000000..a95c8da64fdb07 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicestart.getserverinfo.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) > [getServerInfo](./kibana-plugin-core-server.httpservicestart.getserverinfo.md) + +## HttpServiceStart.getServerInfo property + +Provides common [information](./kibana-plugin-core-server.httpserverinfo.md) about the running http server. + +Signature: + +```typescript +getServerInfo: () => HttpServerInfo; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicestart.md b/docs/development/core/server/kibana-plugin-core-server.httpservicestart.md index 53239da516b25e..bc99c1217f72bd 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicestart.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicestart.md @@ -15,5 +15,7 @@ export interface HttpServiceStart | Property | Type | Description | | --- | --- | --- | -| [isListening](./kibana-plugin-core-server.httpservicestart.islistening.md) | (port: number) => boolean | Indicates if http server is listening on a given port | +| [auth](./kibana-plugin-core-server.httpservicestart.auth.md) | HttpAuth | Auth status. See [HttpAuth](./kibana-plugin-core-server.httpauth.md) | +| [basePath](./kibana-plugin-core-server.httpservicestart.basepath.md) | IBasePath | Access or manipulate the Kibana base path See [IBasePath](./kibana-plugin-core-server.ibasepath.md). | +| [getServerInfo](./kibana-plugin-core-server.httpservicestart.getserverinfo.md) | () => HttpServerInfo | Provides common [information](./kibana-plugin-core-server.httpserverinfo.md) about the running http server. | diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 147a72016b2351..0f1bbbe7176e50 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -85,6 +85,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [EnvironmentMode](./kibana-plugin-core-server.environmentmode.md) | | | [ErrorHttpResponseOptions](./kibana-plugin-core-server.errorhttpresponseoptions.md) | HTTP response parameters | | [FakeRequest](./kibana-plugin-core-server.fakerequest.md) | Fake request object created manually by Kibana plugins. | +| [HttpAuth](./kibana-plugin-core-server.httpauth.md) | | | [HttpResources](./kibana-plugin-core-server.httpresources.md) | HttpResources service is responsible for serving static & dynamic assets for Kibana application via HTTP. Provides API allowing plug-ins to respond with: - a pre-configured HTML page bootstrapping Kibana client app - custom HTML page - custom JS script file. | | [HttpResourcesRenderOptions](./kibana-plugin-core-server.httpresourcesrenderoptions.md) | Allows to configure HTTP response parameters | | [HttpResourcesServiceToolkit](./kibana-plugin-core-server.httpresourcesservicetoolkit.md) | Extended set of [KibanaResponseFactory](./kibana-plugin-core-server.kibanaresponsefactory.md) helpers used to respond with HTML or JS resource. | @@ -157,6 +158,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsExportResultDetails](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) | Structure of the export result details entry | | [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) | | | [SavedObjectsFindResponse](./kibana-plugin-core-server.savedobjectsfindresponse.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | +| [SavedObjectsFindResult](./kibana-plugin-core-server.savedobjectsfindresult.md) | | | [SavedObjectsImportConflictError](./kibana-plugin-core-server.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | | [SavedObjectsImportError](./kibana-plugin-core-server.savedobjectsimporterror.md) | Represents a failure to import. | | [SavedObjectsImportMissingReferencesError](./kibana-plugin-core-server.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md index a1b1a7a056206d..4ed069d1598fe4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.md @@ -20,6 +20,6 @@ export interface SavedObjectsFindResponse | --- | --- | --- | | [page](./kibana-plugin-core-server.savedobjectsfindresponse.page.md) | number | | | [per\_page](./kibana-plugin-core-server.savedobjectsfindresponse.per_page.md) | number | | -| [saved\_objects](./kibana-plugin-core-server.savedobjectsfindresponse.saved_objects.md) | Array<SavedObject<T>> | | +| [saved\_objects](./kibana-plugin-core-server.savedobjectsfindresponse.saved_objects.md) | Array<SavedObjectsFindResult<T>> | | | [total](./kibana-plugin-core-server.savedobjectsfindresponse.total.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.saved_objects.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.saved_objects.md index adad0dd2b11767..7a91367f6ef0bc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.saved_objects.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresponse.saved_objects.md @@ -7,5 +7,5 @@ Signature: ```typescript -saved_objects: Array>; +saved_objects: Array>; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md new file mode 100644 index 00000000000000..e455074a7d11bb --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindResult](./kibana-plugin-core-server.savedobjectsfindresult.md) + +## SavedObjectsFindResult interface + + +Signature: + +```typescript +export interface SavedObjectsFindResult extends SavedObject +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [score](./kibana-plugin-core-server.savedobjectsfindresult.score.md) | number | The Elasticsearch _score of this result. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.score.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.score.md new file mode 100644 index 00000000000000..c6646df6ee4700 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindresult.score.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindResult](./kibana-plugin-core-server.savedobjectsfindresult.md) > [score](./kibana-plugin-core-server.savedobjectsfindresult.score.md) + +## SavedObjectsFindResult.score property + +The Elasticsearch `_score` of this result. + +Signature: + +```typescript +score: number; +``` diff --git a/package.json b/package.json index e91c5e96b78ab4..06dfb4cdfe3872 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,6 @@ "@kbn/test-subj-selector": "0.2.1", "@kbn/ui-framework": "1.0.0", "@kbn/ui-shared-deps": "1.0.0", - "@types/tar": "^4.0.3", "JSONStream": "1.3.5", "abortcontroller-polyfill": "^1.4.0", "accept": "3.0.2", @@ -390,9 +389,10 @@ "@types/styled-components": "^5.1.0", "@types/supertest": "^2.0.5", "@types/supertest-as-promised": "^2.0.38", + "@types/tar": "^4.0.3", + "@types/testing-library__dom": "^6.10.0", "@types/testing-library__react": "^9.1.2", "@types/testing-library__react-hooks": "^3.1.0", - "@types/testing-library__dom": "^6.10.0", "@types/type-detect": "^4.0.1", "@types/uuid": "^3.4.4", "@types/vinyl-fs": "^2.4.11", @@ -486,12 +486,10 @@ "prettier": "^2.0.5", "proxyquire": "1.8.0", "react-popper-tooltip": "^2.10.1", - "react-textarea-autosize": "^7.1.2", "regenerate": "^1.4.0", "sass-lint": "^1.12.1", "selenium-webdriver": "^4.0.0-alpha.7", "simple-git": "1.116.0", - "simplebar-react": "^2.1.0", "sinon": "^7.4.2", "strip-ansi": "^3.0.1", "supertest": "^3.1.0", diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client.ts b/packages/kbn-dev-utils/src/kbn_client/kbn_client.ts index 2eb6c6cc5aac6b..861ea0988692c5 100644 --- a/packages/kbn-dev-utils/src/kbn_client/kbn_client.ts +++ b/packages/kbn-dev-utils/src/kbn_client/kbn_client.ts @@ -18,7 +18,7 @@ */ import { ToolingLog } from '../tooling_log'; -import { KbnClientRequester, ReqOptions } from './kbn_client_requester'; +import { KibanaConfig, KbnClientRequester, ReqOptions } from './kbn_client_requester'; import { KbnClientStatus } from './kbn_client_status'; import { KbnClientPlugins } from './kbn_client_plugins'; import { KbnClientVersion } from './kbn_client_version'; @@ -26,7 +26,7 @@ import { KbnClientSavedObjects } from './kbn_client_saved_objects'; import { KbnClientUiSettings, UiSettingValues } from './kbn_client_ui_settings'; export class KbnClient { - private readonly requester = new KbnClientRequester(this.log, this.kibanaUrls); + private readonly requester = new KbnClientRequester(this.log, this.kibanaConfig); readonly status = new KbnClientStatus(this.requester); readonly plugins = new KbnClientPlugins(this.status); readonly version = new KbnClientVersion(this.status); @@ -43,10 +43,10 @@ export class KbnClient { */ constructor( private readonly log: ToolingLog, - private readonly kibanaUrls: string[], + private readonly kibanaConfig: KibanaConfig, private readonly uiSettingDefaults?: UiSettingValues ) { - if (!kibanaUrls.length) { + if (!kibanaConfig.url) { throw new Error('missing Kibana urls'); } } diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client_requester.ts b/packages/kbn-dev-utils/src/kbn_client/kbn_client_requester.ts index ea4159de557499..2aba2be56f277b 100644 --- a/packages/kbn-dev-utils/src/kbn_client/kbn_client_requester.ts +++ b/packages/kbn-dev-utils/src/kbn_client/kbn_client_requester.ts @@ -16,10 +16,9 @@ * specific language governing permissions and limitations * under the License. */ - import Url from 'url'; - -import Axios from 'axios'; +import Https from 'https'; +import Axios, { AxiosResponse } from 'axios'; import { isAxiosRequestError, isAxiosResponseError } from '../axios'; import { ToolingLog } from '../tooling_log'; @@ -70,20 +69,38 @@ const delay = (ms: number) => setTimeout(resolve, ms); }); +export interface KibanaConfig { + url: string; + ssl?: { + enabled: boolean; + key: string; + certificate: string; + certificateAuthorities: string; + }; +} + export class KbnClientRequester { - constructor(private readonly log: ToolingLog, private readonly kibanaUrls: string[]) {} + private readonly httpsAgent: Https.Agent | null; + constructor(private readonly log: ToolingLog, private readonly kibanaConfig: KibanaConfig) { + this.httpsAgent = + kibanaConfig.ssl && kibanaConfig.ssl.enabled + ? new Https.Agent({ + cert: kibanaConfig.ssl.certificate, + key: kibanaConfig.ssl.key, + ca: kibanaConfig.ssl.certificateAuthorities, + }) + : null; + } private pickUrl() { - const url = this.kibanaUrls.shift()!; - this.kibanaUrls.push(url); - return url; + return this.kibanaConfig.url; } public resolveUrl(relativeUrl: string = '/') { return Url.resolve(this.pickUrl(), relativeUrl); } - async request(options: ReqOptions): Promise { + async request(options: ReqOptions): Promise> { const url = Url.resolve(this.pickUrl(), options.path); const description = options.description || `${options.method} ${url}`; let attempt = 0; @@ -93,7 +110,7 @@ export class KbnClientRequester { attempt += 1; try { - const response = await Axios.request({ + const response = await Axios.request({ method: options.method, url, data: options.body, @@ -101,9 +118,10 @@ export class KbnClientRequester { headers: { 'kbn-xsrf': 'kbn-client', }, + httpsAgent: this.httpsAgent, }); - return response.data; + return response; } catch (error) { const conflictOnGet = isConcliftOnGetError(error); const requestedRetries = options.retries !== undefined; diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client_saved_objects.ts b/packages/kbn-dev-utils/src/kbn_client/kbn_client_saved_objects.ts index e671061b343523..7334c6353debfc 100644 --- a/packages/kbn-dev-utils/src/kbn_client/kbn_client_saved_objects.ts +++ b/packages/kbn-dev-utils/src/kbn_client/kbn_client_saved_objects.ts @@ -71,12 +71,13 @@ export class KbnClientSavedObjects { public async migrate() { this.log.debug('Migrating saved objects'); - return await this.requester.request({ + const { data } = await this.requester.request({ description: 'migrate saved objects', path: uriencode`/internal/saved_objects/_migrate`, method: 'POST', body: {}, }); + return data; } /** @@ -85,11 +86,12 @@ export class KbnClientSavedObjects { public async get>(options: GetOptions) { this.log.debug('Gettings saved object: %j', options); - return await this.requester.request>({ + const { data } = await this.requester.request>({ description: 'get saved object', path: uriencode`/api/saved_objects/${options.type}/${options.id}`, method: 'GET', }); + return data; } /** @@ -98,7 +100,7 @@ export class KbnClientSavedObjects { public async create>(options: IndexOptions) { this.log.debug('Creating saved object: %j', options); - return await this.requester.request>({ + const { data } = await this.requester.request>({ description: 'update saved object', path: options.id ? uriencode`/api/saved_objects/${options.type}/${options.id}` @@ -113,6 +115,7 @@ export class KbnClientSavedObjects { references: options.references, }, }); + return data; } /** @@ -121,7 +124,7 @@ export class KbnClientSavedObjects { public async update>(options: UpdateOptions) { this.log.debug('Updating saved object: %j', options); - return await this.requester.request>({ + const { data } = await this.requester.request>({ description: 'update saved object', path: uriencode`/api/saved_objects/${options.type}/${options.id}`, query: { @@ -134,6 +137,7 @@ export class KbnClientSavedObjects { references: options.references, }, }); + return data; } /** @@ -142,10 +146,12 @@ export class KbnClientSavedObjects { public async delete(options: GetOptions) { this.log.debug('Deleting saved object %s/%s', options); - return await this.requester.request({ + const { data } = await this.requester.request({ description: 'delete saved object', path: uriencode`/api/saved_objects/${options.type}/${options.id}`, method: 'DELETE', }); + + return data; } } diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client_status.ts b/packages/kbn-dev-utils/src/kbn_client/kbn_client_status.ts index 22baf4a3304168..4f203e73620f35 100644 --- a/packages/kbn-dev-utils/src/kbn_client/kbn_client_status.ts +++ b/packages/kbn-dev-utils/src/kbn_client/kbn_client_status.ts @@ -52,10 +52,11 @@ export class KbnClientStatus { * Get the full server status */ async get() { - return await this.requester.request({ + const { data } = await this.requester.request({ method: 'GET', path: 'api/status', }); + return data; } /** diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client_ui_settings.ts b/packages/kbn-dev-utils/src/kbn_client/kbn_client_ui_settings.ts index dbfa87e70032bf..6ee2d3bfe59b0c 100644 --- a/packages/kbn-dev-utils/src/kbn_client/kbn_client_ui_settings.ts +++ b/packages/kbn-dev-utils/src/kbn_client/kbn_client_ui_settings.ts @@ -57,10 +57,11 @@ export class KbnClientUiSettings { * Unset a uiSetting */ async unset(setting: string) { - return await this.requester.request({ + const { data } = await this.requester.request({ path: uriencode`/api/kibana/settings/${setting}`, method: 'DELETE', }); + return data; } /** @@ -105,11 +106,11 @@ export class KbnClientUiSettings { } private async getAll() { - const resp = await this.requester.request({ + const { data } = await this.requester.request({ path: '/api/kibana/settings', method: 'GET', }); - return resp.settings; + return data.settings; } } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index 29ec28175a8519..e9aeee87f1a3b2 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -38,6 +38,14 @@ const urlPartsSchema = () => password: Joi.string(), pathname: Joi.string().regex(/^\//, 'start with a /'), hash: Joi.string().regex(/^\//, 'start with a /'), + ssl: Joi.object() + .keys({ + enabled: Joi.boolean().default(false), + certificate: Joi.string().optional(), + certificateAuthorities: Joi.string().optional(), + key: Joi.string().optional(), + }) + .default(), }) .default(); @@ -122,6 +130,7 @@ export const schema = Joi.object() type: Joi.string().valid('chrome', 'firefox', 'ie', 'msedge').default('chrome'), logPollingMs: Joi.number().default(100), + acceptInsecureCerts: Joi.boolean().default(false), }) .default(), diff --git a/packages/kbn-test/src/kbn/index.js b/packages/kbn-test/src/kbn/index.ts similarity index 100% rename from packages/kbn-test/src/kbn/index.js rename to packages/kbn-test/src/kbn/index.ts diff --git a/packages/kbn-test/src/kbn/kbn_test_config.js b/packages/kbn-test/src/kbn/kbn_test_config.ts similarity index 76% rename from packages/kbn-test/src/kbn/kbn_test_config.js rename to packages/kbn-test/src/kbn/kbn_test_config.ts index c43efabb4b747c..909c94098cf5d6 100644 --- a/packages/kbn-test/src/kbn/kbn_test_config.js +++ b/packages/kbn-test/src/kbn/kbn_test_config.ts @@ -16,26 +16,34 @@ * specific language governing permissions and limitations * under the License. */ - -import { kibanaTestUser } from './users'; import url from 'url'; +import { kibanaTestUser } from './users'; + +interface UrlParts { + protocol?: string; + hostname?: string; + port?: number; + auth?: string; + username?: string; + password?: string; +} export const kbnTestConfig = new (class KbnTestConfig { getPort() { return this.getUrlParts().port; } - getUrlParts() { + getUrlParts(): UrlParts { // allow setting one complete TEST_KIBANA_URL for ES like https://elastic:changeme@example.com:9200 if (process.env.TEST_KIBANA_URL) { const testKibanaUrl = url.parse(process.env.TEST_KIBANA_URL); return { - protocol: testKibanaUrl.protocol.slice(0, -1), + protocol: testKibanaUrl.protocol?.slice(0, -1), hostname: testKibanaUrl.hostname, - port: parseInt(testKibanaUrl.port, 10), + port: testKibanaUrl.port ? parseInt(testKibanaUrl.port, 10) : undefined, auth: testKibanaUrl.auth, - username: testKibanaUrl.auth.split(':')[0], - password: testKibanaUrl.auth.split(':')[1], + username: testKibanaUrl.auth?.split(':')[0], + password: testKibanaUrl.auth?.split(':')[1], }; } @@ -44,7 +52,7 @@ export const kbnTestConfig = new (class KbnTestConfig { return { protocol: process.env.TEST_KIBANA_PROTOCOL || 'http', hostname: process.env.TEST_KIBANA_HOSTNAME || 'localhost', - port: parseInt(process.env.TEST_KIBANA_PORT, 10) || 5620, + port: process.env.TEST_KIBANA_PORT ? parseInt(process.env.TEST_KIBANA_PORT, 10) : 5620, auth: `${username}:${password}`, username, password, diff --git a/packages/kbn-test/src/kbn/users.js b/packages/kbn-test/src/kbn/users.ts similarity index 100% rename from packages/kbn-test/src/kbn/users.js rename to packages/kbn-test/src/kbn/users.ts diff --git a/rfcs/text/0011_reporting_as_an_api.md b/rfcs/text/0011_reporting_as_an_api.md new file mode 100644 index 00000000000000..8975d51dbd909d --- /dev/null +++ b/rfcs/text/0011_reporting_as_an_api.md @@ -0,0 +1,284 @@ +- Start Date: 2020-04-23 +- RFC PR: (leave this empty) +- Kibana Issue: (leave this empty) + +# Summary + +The reporting plugin is migrating to a purely REST API interface, deprecating page-level integrations such as Dashboard and Discover. + +# Basic example + +Currently, reporting does expose an API for Dashboard exports as seen below. + +```sh +# Massively truncated URL +curl -x POST http://localhost:5601/api/reporting/generate/printablePdf?jobParams=%28browserTimezone%3AAmerica%2FLos_Angeles%2Clayout... +``` + +Going forth, reporting would only offer a JSON-based REST API, deprecating older ad-hoc solutions: + +```sh +curl -x POST http://localhost:5601/api/reporting/pdf +{ + “baseUrl”: “/my/kibana/page/route?foo=bar&reporting=true”, + "waitUntil": { + "event": “complete”, + }, + “viewport”: { + “width”: 1920, + “height”: 1080, + "scale": 1 + }, + "mediaType": "screen" +} +``` + +A simple JSON response is returned, with an identifier to query for status. + +```json +{ + "id": "123" +} +``` + +Further information can be found via GET call with the job's ID: + +```sh +curl http://localhost:5601/api/reporting/123/status +``` + +# Motivation + +The reporting functionality that currently exists in Kibana was originally purpose-built for the Discover, Dashboard and Canvas applications. Because of this, reportings underlying technologies and infrastructure are hard to improve upon and make generally available for pages across Kibana. Currently, the team has to: + +- Build and maintain our own Chromium binary for the 3 main operating systems we support. +- Fix and help troubleshoot issues encountered by our users and their complex deployment topologies. +- Ensure successful operation in smaller-sized cloud deployments. +- Help other teams get their applications “reportable”. +- Continue to adapt changes in Discover and Dashboard so that they can be reportable (WebGL for instance). + +In order to ensure the reporting works in a secure manner, we also maintain complex logic that ensures nothing goes wrong during report generation. These include: + +- Home-rolled security role checks. +- A custom-built network firewall via puppeteer to ensure chromium can’t be hijacked for nefarious purposes. +- Network request interception to apply authorization contexts. +- Configuration checks for both Elasticsearch and Kibana, to ensure the user's configuration is valid and workable. +- CSV formula injection checks, encodings, and other challenges. + +It's important that there be a barrier between *how* reporting works, as well as *how* an application in Kibana is rendered. As of today no such barrier exists. + +While we understand that many of these requirements are similar across teams, however, in order to better serve the application teams that depend on reporting the time has come to rethink reportings role inside of Kibana, and how we can scale it across our product suite. + +# Detailed design + +## REST API + +Though we plan to support additional functionality longer-term (for instance a client-api or support for scheduling), the initial product will solely be a REST API involving a 4 part life-cycle: + +1. Starting a new job. +2. Querying a job's status. +3. Downloading the job's results. +4. Deleting a job + +Reporting will return a list of HTTP codes to indicate acceptance or rejection of any HTTP interaction: + +**Possible HTTP responses** + +`200`: Job is accepted and is queued for execution + +`204`: OK response, but no message returned (used in DELETE calls) + +`400`: There was a malformation of the request, and consumers should review the returned message + +`403`: The user is not allowed to create a job + +`404`: Job wasn't found + +`401`: The request isn't properly authorized + +### 1. Starting a new job + +The primary export type in this phase will be a PDF binary (retrieved in Step 3). Registering can be as complex as below: + +```sh +curl -x POST http://kibana-host:kibana-port/api/reporting/pdf +[{ + “baseUrl”: “/my/kibana/page/route?page=1&reporting=true”, + "waitUntil": { + "event": “complete”, + }, + “viewport”: { + “width”: 1920, + “height”: 1080, + "scale": 2 + }, + "mediaType": "screen", + "timeout": 30000, +}, { + “baseUrl”: “/my/kibana/page/route?page=2&reporting=true”, + "waitUntil": { + "event": “complete”, + }, + “viewport”: { + “width”: 1920, + “height”: 1080, + "scale": 2 + }, + "mediaType": "screen", + "timeout": 30000, +}] +``` + +In the above example, a consumer posts an array of URLs to be exported. When doing so, the assumption here is that the pages relate to each other in some fashion (workpad's in Canvas, for instance), and thus can be optimized by using the page and browser objects. It should be noted that even though we're given a collection of pages to export that *they'll be rendered in series and not parallel*. + +`baseUrl: string`: The URL of the page you wish to export, relative to Kibana's default path. For instance, if canvas wanted to export a page at `http://localhost:5601/app/canvas#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1`, the `baseUrl` would be `/app/canvas#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1`. This is done to prevent our chromium process from being "hijacked" to navigate elsewhere. You're free to do whatever you'd like for the URL, including any query-string parameters or other variables in order to properly render you page for reporting. For instance, you'll notice the `reporting=true` param listed above. + +`waitUntil: { event: string; selector: string }`: An object, specifying a custom `DOM` event to "listen" for in our chromium process, or the presence of a DOM selector. Either options are valid, however we won't allow for both options to be set. For instance, if a page inserts a `
` in its markup, then the appropriate payload would be: + +```json +"waitUntil": { + "selector": "div.loaded", +}, +``` + +`viewport: { width: number; height: number; scale: number }`: Viewport will allow consumers the ability to make rigid dimensions of the browser, such that the formatting of their pages sized appropriately. Scale, in this context, refers roughly to pixel density if there's a need for a higher resolution. A page that needs high-resolution PDF, could set this by: + +```json +“viewport”: { + “width”: 1920, + “height”: 1080, + "scale": 2 +}, +``` + +`mediaType: "screen" | "print"`: It's often the case that pages would like to use print media-queries, and this allows for opting-in or out of that behavior. For example, if a page wishes to utilize its print media queries, a payload with: + +```json +"mediaType": "print" +``` + +`timeout: number`: When present, this allows for consumers to override the default reporting timeout. This is useful if a job is known to take much longer to process, or supporting our users without requiring them to restart their Kibana servers for a simple configuration change. Value here is milliseconds. + +```json +"timeout": 60000 +``` + +**Full job creation example:** + +```curl +curl -x POST http://localhost:5601/api/reporting/pdf +[{ + “baseUrl”: “/my/kibana/page/route?page=1&reporting=true”, + "waitUntil": { + "event": “complete”, + }, + “viewport”: { + “width”: 1920, + “height”: 1080, + "scale": 2 + }, + "mediaType": "screen", + "timeout": 30000, +}, { + “baseUrl”: “/my/kibana/page/route?page=2&reporting=true”, + "waitUntil": { + "event": “complete”, + }, + “viewport”: { + “width”: 1920, + “height”: 1080, + "scale": 2 + }, + "mediaType": "screen", + "timeout": 30000, +}] + +# Response (note the single ID of the response) +# 200 Content-Type application/json +{ + "id": "123" +} +``` + +### 2. Querying and altering a job's status. + +Once created, a user can simply issue simple GET call to see the status of the job. + +**Get a job's status:** + +```curl +curl -x GET http://localhost:5601/api/reporting/123/status + +# Response +# 200 Content-Type application/json +# We might provide other meta-data here as well when required +{ + "status": "pending", + "elapsedTime": 12345 +} +``` + +Possible types for `status` here are: `pending`, `running`, `complete`, `complete-warnings`, `failed`, or `timedout`. We can add more detail here if needed, such as the current URL being operated or whatever other information is valuable to consumers. + +### 4. Deleting a job + +A DELETE call will remove the report. If a report is in pending/running state, this will attempt to terminate the running job. Once a report is complete, the call to delete will permanently (hard delete) remove the job's output in ElasticSearch. + +When successfully deleted, reporting will simply respond with a `204` HTTP code, indicating success. + +```curl +curl -x DELETE http://localhost:5601/api/reporting/123 + +# Response (no body, 204 indicates success) +# 204 Content-Type text/plain;charset=UTF-8 +``` + +# Drawbacks + +Due to the new nature of this RFC, there are definitely drawbacks to this approach short-term. These short-term drawbacks become miniscule longer-term, since the work being done here frees both reporting and downstream teams to operate in parallel. + +- Initial work to build this pipeline will freeze some current efforts (scheduled reports, etc). +- Doesn't solve complex architectural issues experienced by our customers. +- Requires work to migrate our existing apps (Canvas, dashboard, visualizations). +- Doesn't offer any performance characteristics over our current implementation. + +Though there's some acute pain felt here shorter term, they pale in comparison to building custom ad-hoc solutions for each application inside of Kibana. + +# Alternatives + +Going through the process of developing this current RFC, we did entertain a few other strategies: + +## No changes in how we operate + +This strategy doesn't scale beyond the current two team members since we field many support issues that are application-specific, and not reporting specific. This keeps our trajectory where it currently is, short term, but hamstrings us longer term. Unfortunately, for teams to have the best experience with regards to reporting, they'll need to have ownership on the rendering aspects of their pages. + +## A new plugin + +We debated offering a new plugin, or having apps consume this type of service as a plugin, but ultimately it was too much overhead for the nature of what we're offering. More information on the prior RFC is here: https://github.com/elastic/kibana/pull/59084. + +## Each page builds its own pipeline + +This would allow teams to operate how they best see fit, but would come with a host of issues: + +- Each team would need to ramp up on how we handle chromium and all of its sharp edges. +- Potential for many requests to be in flight at once, causing exhaustion of resources. +- Mixed experience across different apps, and varying degrees of success. +- No central management of a users general reports. + +# Adoption strategy + +After work on the service is complete in its initial phase, we'll begin to migrate the Dashboard app over to the new service. This will give a clear example of: + +- Moving a complex page over to this service. +- Where the divisions of labor reside (who does what). +- How to embed rendering-specific logic into your pages. + +Since reporting only exists on a few select pages, there won't be need for a massive migration effort. Instead, folks wanting to move over to the new rendering service can simply take a look at how Dashboard handles their exporting. + +In short, the adoption strategy is fairly minimal due to the lack of pages being reported on. + +# Unresolved questions + +- How to troubleshoot complex customer environments? +- When do we do this work? +- Nuances in the API, are we missing other critical information? diff --git a/src/core/TESTING.md b/src/core/TESTING.md index bed41ab583496e..a62922d9b5d64b 100644 --- a/src/core/TESTING.md +++ b/src/core/TESTING.md @@ -29,6 +29,14 @@ This document outlines best practices and patterns for testing Kibana Plugins. - [Testing dependencies usages](#testing-dependencies-usages) - [Testing components consuming the dependencies](#testing-components-consuming-the-dependencies) - [Testing optional plugin dependencies](#testing-optional-plugin-dependencies) + - [RXJS testing](#rxjs-testing) + - [Testing RXJS observables with marble](#rxjs-testing-with-marble) + - [Precondition](#preconditions-2) + - [Examples](#example-5) + - [Testing an interval based observable](#testing-an-interval-based-observable) + - [Testing observable completion](#testing-observable-completion) + - [Testing observable errors](#testing-observable-errors) + - [Testing promise based observables](#testing-promise-based-observables) ## Strategy @@ -1087,3 +1095,271 @@ describe('Plugin', () => { }); }); ``` + +## RXJS testing + +### Testing RXJS observables with marble + +Testing observable based APIs can be challenging, specially when asynchronous operators or sources are used, +or when trying to assert against emission's timing. + +Fortunately, RXJS comes with it's own `marble` testing module to greatly facilitate that kind of testing. + +See [the official doc](https://rxjs-dev.firebaseapp.com/guide/testing/marble-testing) for more information about marble testing. + +### Preconditions + +The following examples all assume that the following snippet is included in every test file: + +```typescript +import { TestScheduler } from 'rxjs/testing'; + +const getTestScheduler = () => + new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); +``` + +`getTestScheduler` creates a `TestScheduler` that is wired on `jest`'s `expect` statement when comparing an observable's time frame. + +### Examples + +#### Testing an interval based observable + +Here is a very basic example of an interval-based API: + +```typescript +class FooService { + setup() { + return { + getUpdate$: () => { + return interval(100).pipe(map((count) => `update-${count + 1}`)); + }, + }; + } +} +``` + +If we were to be adding a test that asserts the correct behavior of this API without using marble testing, it +would probably be something like: + +```typescript +it('getUpdate$ emits updates every 100ms', async () => { + const service = new FooService(); + const { getUpdate$ } = service.setup(); + expect(await getUpdate$().pipe(take(3), toArray()).toPromise()).toEqual([ + 'update-1', + 'update-2', + 'update-3', + ]); +}); +``` + +Note that if we are able to test the correct value of each emission, we don't have any way to assert that +the interval of 100ms was respected. Even using a subscription based test to try to do so would result +in potential flakiness, as the subscription execution could trigger on the `101ms` time frame for example. + +It also may be important to note: +- as we need to convert the observable to a promise and wait for the result, the test is `async` +- we need to perform observable transformation (`take` + `toArray`) in the test to have an usable value to assert against. + +Marble testing would allow to get rid of these limitations. An equivalent and improved marble test could be: + +```typescript + describe('getUpdate$', () => { + it('emits updates every 100ms', () => { + getTestScheduler().run(({ expectObservable }) => { + const { getUpdate$ } = service.setup(); + expectObservable(getUpdate$(), '301ms !').toBe('100ms a 99ms b 99ms c', { + a: 'update-1', + b: 'update-2', + c: 'update-3', + }); + }); + }); + }); +``` + +Notes: +- the test is now synchronous +- the second parameter of `expectObservable` (`'301ms !'`) is used to perform manual unsubscription to the observable, as + `interval` never ends. +- an emission is considered a time frame, meaning that after the initial `a` emission, we are at the frame `101`, not `100` + which is why we are then only using a `99ms` gap between a->b and b->c. + +#### Testing observable completion + +Let's 'improve' our `getUpdate$` API by allowing the consumer to manually terminate the observable chain using +a new `abort$` option: + +```typescript +class FooService { + setup() { + return { + // note: using an abortion observable is usually an anti-pattern, as unsubscribing from the observable + // is, most of the time, a better solution. This is only used for the example purpose. + getUpdate$: ({ abort$ = EMPTY }: { abort$?: Observable } = {}) => { + return interval(100).pipe( + takeUntil(abort$), + map((count) => `update-${count + 1}`) + ); + }, + }; + } +} +``` + +We would then add a test to assert than this new option usage is respected: + +```typescript +it('getUpdate$ completes when `abort$` emits', () => { + const service = new FooService(); + getTestScheduler().run(({ expectObservable, hot }) => { + const { getUpdate$ } = service.setup(); + const abort$ = hot('149ms a', { a: undefined }); + expectObservable(getUpdate$({ abort$ })).toBe('100ms a 48ms |', { + a: 'update-1', + }); + }); +}); +``` + +Notes: + - the `|` symbol represents the completion of the observable. + - we are here using the `hot` testing utility to create the `abort$` observable to ensure correct emission timing. + +#### Testing observable errors + +Testing errors thrown by the observable is very close to the previous examples and is done using +the third parameter of `expectObservable`. + +Say we have a service in charge of processing data from an observable and returning the results in a new observable: + +```typescript +interface SomeDataType { + id: string; +} + +class BarService { + setup() { + return { + processDataStream: (data$: Observable) => { + return data$.pipe( + map((data) => { + if (data.id === 'invalid') { + throw new Error(`invalid data: '${data.id}'`); + } + return { + ...data, + processed: 'additional-data', + }; + }) + ); + }, + }; + } +} +``` + +We could write a test that asserts the service properly emit processed results until an invalid data is encountered: + +```typescript +it('processDataStream throw an error when processing invalid data', () => { + getTestScheduler().run(({ expectObservable, hot }) => { + const service = new BarService(); + const { processDataStream } = service.setup(); + + const data = hot('--a--b--(c|)', { + a: { id: 'a' }, + b: { id: 'invalid' }, + c: { id: 'c' }, + }); + + expectObservable(processDataStream(data)).toBe( + '--a--#', + { + a: { id: 'a', processed: 'additional-data' }, + }, + `'[Error: invalid data: 'invalid']'` + ); + }); +}); +``` + +Notes: + - the `-` symbol represents one virtual time frame. + - the `#` symbol represents an error. + - when throwing custom `Error` classes, the assertion can be against an error instance, but this doesn't work + with base errors. + +#### Testing promise based observables + +In some cases, the observable we want to test is based on a Promise (like `of(somePromise).pipe(...)`). This can occur +when using promise-based services, such as core's `http`, for instance. + +```typescript +export const callServerAPI = ( + http: HttpStart, + body: Record, + { abort$ }: { abort$: Observable } +): Observable => { + let controller: AbortController | undefined; + if (abort$) { + controller = new AbortController(); + abort$.subscribe(() => { + controller!.abort(); + }); + } + return from( + http.post('/api/endpoint', { + body, + signal: controller?.signal, + }) + ).pipe( + takeUntil(abort$ ?? EMPTY), + map((response) => response.results) + ); +}; +``` + +Testing that kind of promise based observable does not work out of the box with marble testing, as the asynchronous promise resolution +is not handled by the test scheduler's 'sandbox'. + +Fortunately, there are workarounds for this problem. The most common one being to mock the promise-returning API to return +an observable instead for testing, as `of(observable)` also works and returns the input observable. + +Note that when doing so, the test suite must also include tests using a real promise value to ensure correct behavior in real situation. + +```typescript + +// NOTE: test scheduler do not properly work with promises because of their asynchronous nature. +// we are cheating here by having `http.post` return an observable instead of a promise. +// this still allows more finely grained testing about timing, and asserting that the method +// works properly when `post` returns a real promise is handled in other tests of this suite + +it('callServerAPI result observable emits when the response is received', () => { + const http = httpServiceMock.createStartContract(); + getTestScheduler().run(({ expectObservable, hot }) => { + // need to cast the observable as `any` because http.post.mockReturnValue expects a promise, see previous comment + http.post.mockReturnValue(hot('---(a|)', { a: { someData: 'foo' } }) as any); + + const results = callServerAPI(http, { query: 'term' }, {}); + + expectObservable(results).toBe('---(a|)', { + a: { someData: 'foo' }, + }); + }); +}); + +it('completes without returning results if aborted$ emits before the response', () => { + const http = httpServiceMock.createStartContract(); + getTestScheduler().run(({ expectObservable, hot }) => { + // need to cast the observable as `any` because http.post.mockReturnValue expects a promise, see previous comment + http.post.mockReturnValue(hot('---(a|)', { a: { someData: 'foo' } }) as any); + const aborted$ = hot('-(a|)', { a: undefined }); + const results = callServerAPI(http, { query: 'term' }, { aborted$ }); + + expectObservable(results).toBe('-|'); + }); +}); +``` \ No newline at end of file diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 5c8eca4a33ec57..cb279b2cc4c8f9 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -303,7 +303,6 @@ export class SavedObjectsClient { query, }); return request.then((resp) => { - resp.saved_objects = resp.saved_objects.map((d) => this.createSavedObject(d)); return renameKeys< PromiseType>, SavedObjectsFindResponsePublic @@ -314,7 +313,10 @@ export class SavedObjectsClient { per_page: 'perPage', page: 'page', }, - resp + { + ...resp, + saved_objects: resp.saved_objects.map((d) => this.createSavedObject(d)), + } ) as SavedObjectsFindResponsePublic; }); }; diff --git a/src/core/server/capabilities/capabilities_service.test.ts b/src/core/server/capabilities/capabilities_service.test.ts index 7d2e7391aa8d4b..42dc1604281b82 100644 --- a/src/core/server/capabilities/capabilities_service.test.ts +++ b/src/core/server/capabilities/capabilities_service.test.ts @@ -17,19 +17,19 @@ * under the License. */ -import { httpServiceMock, HttpServiceSetupMock } from '../http/http_service.mock'; +import { httpServiceMock, InternalHttpServiceSetupMock } from '../http/http_service.mock'; import { mockRouter, RouterMock } from '../http/router/router.mock'; import { CapabilitiesService, CapabilitiesSetup } from './capabilities_service'; import { mockCoreContext } from '../core_context.mock'; describe('CapabilitiesService', () => { - let http: HttpServiceSetupMock; + let http: InternalHttpServiceSetupMock; let service: CapabilitiesService; let setup: CapabilitiesSetup; let router: RouterMock; beforeEach(() => { - http = httpServiceMock.createSetupContract(); + http = httpServiceMock.createInternalSetupContract(); router = mockRouter.create(); http.createRouter.mockReturnValue(router); service = new CapabilitiesService(mockCoreContext.create()); diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index e7dab3807733a5..8bf0df74186a99 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -39,7 +39,7 @@ const delay = async (durationMs: number) => let elasticsearchService: ElasticsearchService; const configService = configServiceMock.create(); const deps = { - http: httpServiceMock.createSetupContract(), + http: httpServiceMock.createInternalSetupContract(), }; configService.atPath.mockReturnValue( new BehaviorSubject({ diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 1798c3a921da42..9a5deb9b455627 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -1046,17 +1046,6 @@ describe('setup contract', () => { }); }); - describe('#isTlsEnabled', () => { - it('returns "true" if TLS enabled', async () => { - const { isTlsEnabled } = await server.setup(configWithSSL); - expect(isTlsEnabled).toBe(true); - }); - it('returns "false" if TLS not enabled', async () => { - const { isTlsEnabled } = await server.setup(config); - expect(isTlsEnabled).toBe(false); - }); - }); - describe('#getServerInfo', () => { it('returns correct information', async () => { let { getServerInfo } = await server.setup(config); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 8089ee901fa65c..d4615dd4744e58 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -53,7 +53,6 @@ export interface HttpServerSetup { registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; registerOnPreResponse: HttpServiceSetup['registerOnPreResponse']; - isTlsEnabled: HttpServiceSetup['isTlsEnabled']; getAuthHeaders: GetAuthHeaders; auth: { get: GetAuthState; @@ -133,7 +132,6 @@ export class HttpServer { port: config.port, protocol: this.server!.info.protocol, }), - isTlsEnabled: config.ssl.enabled, // Return server instance with the connection options so that we can properly // bridge core and the "legacy" Kibana internally. Once this bridge isn't // needed anymore we shouldn't return the instance from this method. diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 0788a8f2af7a14..02ae6f5d95a879 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -19,9 +19,14 @@ import { Server } from 'hapi'; import { CspConfig } from '../csp'; -import { mockRouter } from './router/router.mock'; +import { mockRouter, RouterMock } from './router/router.mock'; import { configMock } from '../config/config.mock'; -import { InternalHttpServiceSetup } from './types'; +import { + InternalHttpServiceSetup, + HttpServiceSetup, + HttpServiceStart, + InternalHttpServiceStart, +} from './types'; import { HttpService } from './http_service'; import { AuthStatus } from './auth_state_storage'; import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; @@ -32,7 +37,23 @@ import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; type BasePathMocked = jest.Mocked; type AuthMocked = jest.Mocked; -export type HttpServiceSetupMock = jest.Mocked & { + +export type HttpServiceSetupMock = jest.Mocked< + Omit +> & { + basePath: BasePathMocked; + createRouter: jest.MockedFunction<() => RouterMock>; +}; +export type InternalHttpServiceSetupMock = jest.Mocked< + Omit +> & { + basePath: BasePathMocked; + createRouter: jest.MockedFunction<(path: string) => RouterMock>; +}; +export type HttpServiceStartMock = jest.Mocked & { + basePath: BasePathMocked; +}; +export type InternalHttpServiceStartMock = jest.Mocked & { basePath: BasePathMocked; }; @@ -54,8 +75,8 @@ const createAuthMock = () => { return mock; }; -const createSetupContractMock = () => { - const setupContract: HttpServiceSetupMock = { +const createInternalSetupContractMock = () => { + const mock: InternalHttpServiceSetupMock = { // we can mock other hapi server methods when we need it server: ({ name: 'http-server-test', @@ -77,31 +98,78 @@ const createSetupContractMock = () => { csp: CspConfig.DEFAULT, auth: createAuthMock(), getAuthHeaders: jest.fn(), - isTlsEnabled: false, getServerInfo: jest.fn(), }; - setupContract.createCookieSessionStorageFactory.mockResolvedValue( - sessionStorageMock.createFactory() - ); - setupContract.createRouter.mockImplementation(() => mockRouter.create()); - setupContract.getAuthHeaders.mockReturnValue({ authorization: 'authorization-header' }); - setupContract.getServerInfo.mockReturnValue({ + mock.createCookieSessionStorageFactory.mockResolvedValue(sessionStorageMock.createFactory()); + mock.createRouter.mockImplementation(() => mockRouter.create()); + mock.getAuthHeaders.mockReturnValue({ authorization: 'authorization-header' }); + mock.getServerInfo.mockReturnValue({ host: 'localhost', name: 'kibana', port: 80, protocol: 'http', }); - return setupContract; + return mock; +}; + +const createSetupContractMock = () => { + const internalMock = createInternalSetupContractMock(); + + const mock: HttpServiceSetupMock = { + createCookieSessionStorageFactory: internalMock.createCookieSessionStorageFactory, + registerOnPreAuth: internalMock.registerOnPreAuth, + registerAuth: internalMock.registerAuth, + registerOnPostAuth: internalMock.registerOnPostAuth, + registerOnPreResponse: internalMock.registerOnPreResponse, + basePath: internalMock.basePath, + csp: CspConfig.DEFAULT, + createRouter: jest.fn(), + registerRouteHandlerContext: jest.fn(), + auth: { + get: internalMock.auth.get, + isAuthenticated: internalMock.auth.isAuthenticated, + }, + getServerInfo: internalMock.getServerInfo, + }; + + mock.createRouter.mockImplementation(() => internalMock.createRouter('')); + + return mock; +}; + +const createStartContractMock = () => { + const mock: HttpServiceStartMock = { + auth: createAuthMock(), + basePath: createBasePathMock(), + getServerInfo: jest.fn(), + }; + + return mock; +}; + +const createInternalStartContractMock = () => { + const mock: InternalHttpServiceStartMock = { + ...createStartContractMock(), + isListening: jest.fn(), + }; + + mock.isListening.mockReturnValue(true); + + return mock; }; type HttpServiceContract = PublicMethodsOf; + const createHttpServiceMock = () => { const mocked: jest.Mocked = { setup: jest.fn(), + getStartContract: jest.fn(), start: jest.fn(), stop: jest.fn(), }; - mocked.setup.mockResolvedValue(createSetupContractMock()); + mocked.setup.mockResolvedValue(createInternalSetupContractMock()); + mocked.getStartContract.mockReturnValue(createInternalStartContractMock()); + mocked.start.mockResolvedValue(createInternalStartContractMock()); return mocked; }; @@ -128,7 +196,10 @@ export const httpServiceMock = { create: createHttpServiceMock, createBasePath: createBasePathMock, createAuth: createAuthMock, + createInternalSetupContract: createInternalSetupContractMock, createSetupContract: createSetupContractMock, + createInternalStartContract: createInternalStartContractMock, + createStartContract: createStartContractMock, createOnPreAuthToolkit: createOnPreAuthToolkitMock, createOnPostAuthToolkit: createOnPostAuthToolkitMock, createOnPreResponseToolkit: createOnPreResponseToolkitMock, diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index ae9d53f9fd3db2..c2fd6539181719 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -22,6 +22,7 @@ import { first, map } from 'rxjs/operators'; import { Server } from 'hapi'; import { CoreService } from '../../types'; +import { pick } from '../../utils'; import { Logger, LoggerFactory } from '../logging'; import { ContextSetup } from '../context'; import { Env } from '../config'; @@ -38,7 +39,7 @@ import { RequestHandlerContextContainer, RequestHandlerContextProvider, InternalHttpServiceSetup, - HttpServiceStart, + InternalHttpServiceStart, } from './types'; import { RequestHandlerContext } from '../../server'; @@ -49,7 +50,8 @@ interface SetupDeps { } /** @internal */ -export class HttpService implements CoreService { +export class HttpService + implements CoreService { private readonly httpServer: HttpServer; private readonly httpsRedirectServer: HttpsRedirectServer; private readonly config$: Observable; @@ -59,6 +61,7 @@ export class HttpService implements CoreService { @@ -114,7 +117,16 @@ export class HttpService implements CoreService this.requestHandlerContext!.registerContext(pluginOpaqueId, contextName, provider), }; - return contract; + return this.internalSetup; + } + + // this method exists because we need the start contract to create the `CoreStart` used to start + // the `plugin` and `legacy` services. + public getStartContract(): InternalHttpServiceStart { + return { + ...pick(this.internalSetup!, ['auth', 'basePath', 'getServerInfo']), + isListening: () => this.httpServer.isListening(), + }; } public async start() { @@ -134,9 +146,7 @@ export class HttpService implements CoreService this.httpServer.isListening(), - }; + return this.getStartContract(); } /** diff --git a/src/core/server/http/router/router.mock.ts b/src/core/server/http/router/router.mock.ts index 651d1712100ee5..f85f187164c92a 100644 --- a/src/core/server/http/router/router.mock.ts +++ b/src/core/server/http/router/router.mock.ts @@ -19,7 +19,7 @@ import { IRouter } from './router'; -export type RouterMock = DeeplyMockedKeys; +export type RouterMock = jest.Mocked; function create({ routerPath = '' }: { routerPath?: string } = {}): RouterMock { return { diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index 77e0d6b61692da..7f2e70545d0159 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -47,6 +47,22 @@ export type RequestHandlerContextProvider< TContextName extends keyof RequestHandlerContext > = IContextProvider, TContextName>; +/** + * @public + */ +export interface HttpAuth { + /** + * Gets authentication state for a request. Returned by `auth` interceptor. + * {@link GetAuthState} + */ + get: GetAuthState; + /** + * Returns authentication status for a request. + * {@link IsAuthenticated} + */ + isAuthenticated: IsAuthenticated; +} + /** * Kibana HTTP Service provides own abstraction for work with HTTP stack. * Plugins don't have direct access to `hapi` server and its primitives anymore. Moreover, @@ -185,28 +201,18 @@ export interface HttpServiceSetup { */ basePath: IBasePath; - auth: { - /** - * Gets authentication state for a request. Returned by `auth` interceptor. - * {@link GetAuthState} - */ - get: GetAuthState; - /** - * Returns authentication status for a request. - * {@link IsAuthenticated} - */ - isAuthenticated: IsAuthenticated; - }; - /** - * The CSP config used for Kibana. + * Auth status. + * See {@link HttpAuth} + * + * @deprecated use {@link HttpServiceStart.auth | the start contract} instead. */ - csp: ICspConfig; + auth: HttpAuth; /** - * Flag showing whether a server was configured to use TLS connection. + * The CSP config used for Kibana. */ - isTlsEnabled: boolean; + csp: ICspConfig; /** * Provides ability to declare a handler function for a particular path and HTTP request method. @@ -276,8 +282,28 @@ export interface InternalHttpServiceSetup /** @public */ export interface HttpServiceStart { - /** Indicates if http server is listening on a given port */ - isListening: (port: number) => boolean; + /** + * Access or manipulate the Kibana base path + * See {@link IBasePath}. + */ + basePath: IBasePath; + + /** + * Auth status. + * See {@link HttpAuth} + */ + auth: HttpAuth; + + /** + * Provides common {@link HttpServerInfo | information} about the running http server. + */ + getServerInfo: () => HttpServerInfo; +} + +/** @internal */ +export interface InternalHttpServiceStart extends HttpServiceStart { + /** Indicates if the http server is listening on the configured port */ + isListening: () => boolean; } /** @public */ diff --git a/src/core/server/http_resources/http_resources_service.test.ts b/src/core/server/http_resources/http_resources_service.test.ts index e6f129ba12d78d..80afddc1665708 100644 --- a/src/core/server/http_resources/http_resources_service.test.ts +++ b/src/core/server/http_resources/http_resources_service.test.ts @@ -37,7 +37,7 @@ describe('HttpResources service', () => { describe('#createRegistrar', () => { beforeEach(() => { setupDeps = { - http: httpServiceMock.createSetupContract(), + http: httpServiceMock.createInternalSetupContract(), rendering: renderingMock.createSetupContract(), }; service = new HttpResourcesService(coreContext); diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 658c24f835020d..0da7e5d66cf2a7 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -46,7 +46,7 @@ import { ElasticsearchServiceStart, } from './elasticsearch'; -import { HttpServiceSetup } from './http'; +import { HttpServiceSetup, HttpServiceStart } from './http'; import { HttpResources } from './http_resources'; import { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId } from './plugins'; @@ -121,6 +121,7 @@ export { CustomHttpResponseOptions, GetAuthHeaders, GetAuthState, + HttpAuth, HttpResponseOptions, HttpResponsePayload, HttpServerInfo, @@ -217,6 +218,7 @@ export { SavedObjectsErrorHelpers, SavedObjectsExportOptions, SavedObjectsExportResultDetails, + SavedObjectsFindResult, SavedObjectsFindResponse, SavedObjectsImportConflictError, SavedObjectsImportError, @@ -420,6 +422,8 @@ export interface CoreStart { capabilities: CapabilitiesStart; /** {@link ElasticsearchServiceStart} */ elasticsearch: ElasticsearchServiceStart; + /** {@link HttpServiceStart} */ + http: HttpServiceStart; /** {@link SavedObjectsServiceStart} */ savedObjects: SavedObjectsServiceStart; /** {@link UiSettingsServiceStart} */ diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index 09ec772a417561..f68ab633dcbe65 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -23,7 +23,7 @@ import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { ConfigDeprecationProvider } from './config'; import { ContextSetup } from './context'; import { InternalElasticsearchServiceSetup, ElasticsearchServiceStart } from './elasticsearch'; -import { InternalHttpServiceSetup } from './http'; +import { InternalHttpServiceSetup, InternalHttpServiceStart } from './http'; import { InternalSavedObjectsServiceSetup, InternalSavedObjectsServiceStart, @@ -56,6 +56,7 @@ export interface InternalCoreSetup { export interface InternalCoreStart { capabilities: CapabilitiesStart; elasticsearch: ElasticsearchServiceStart; + http: InternalHttpServiceStart; savedObjects: InternalSavedObjectsServiceStart; uiSettings: InternalUiSettingsServiceStart; } diff --git a/src/core/server/legacy/legacy_internals.test.ts b/src/core/server/legacy/legacy_internals.test.ts index 2ae5e3a3fd1e84..67f2f433d4570d 100644 --- a/src/core/server/legacy/legacy_internals.test.ts +++ b/src/core/server/legacy/legacy_internals.test.ts @@ -45,7 +45,7 @@ describe('LegacyInternals', () => { beforeEach(async () => { uiExports = findLegacyPluginSpecsMock().uiExports; config = configMock.create() as any; - server = httpServiceMock.createSetupContract().server; + server = httpServiceMock.createInternalSetupContract().server; legacyInternals = new LegacyInternals(uiExports, config, server); }); @@ -107,7 +107,7 @@ describe('LegacyInternals', () => { beforeEach(async () => { uiExports = findLegacyPluginSpecsMock().uiExports; config = configMock.create() as any; - server = httpServiceMock.createSetupContract().server; + server = httpServiceMock.createInternalSetupContract().server; legacyInternals = new LegacyInternals(uiExports, config, server); }); diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index d9a0ac5e4ecffb..fb9dc0776716ac 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -85,7 +85,7 @@ beforeEach(() => { elasticsearch: { legacy: {} } as any, uiSettings: uiSettingsServiceMock.createSetupContract(), http: { - ...httpServiceMock.createSetupContract(), + ...httpServiceMock.createInternalSetupContract(), auth: { getAuthHeaders: () => undefined, } as any, @@ -119,7 +119,7 @@ beforeEach(() => { startDeps = { core: { - ...coreMock.createStart(), + ...coreMock.createInternalStart(), savedObjects: savedObjectsServiceMock.createInternalStartContract(), plugins: { contracts: new Map() }, }, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 2ced8b47624060..cfc53b10d91f0a 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -264,6 +264,11 @@ export class LegacyService implements CoreService { const coreStart: CoreStart = { capabilities: startDeps.core.capabilities, elasticsearch: startDeps.core.elasticsearch, + http: { + auth: startDeps.core.http.auth, + basePath: startDeps.core.http.basePath, + getServerInfo: startDeps.core.http.getServerInfo, + }, savedObjects: { getScopedClient: startDeps.core.savedObjects.getScopedClient, createScopedRepository: startDeps.core.savedObjects.createScopedRepository, @@ -302,7 +307,6 @@ export class LegacyService implements CoreService { isAuthenticated: setupDeps.core.http.auth.isAuthenticated, }, csp: setupDeps.core.http.csp, - isTlsEnabled: setupDeps.core.http.isTlsEnabled, getServerInfo: setupDeps.core.http.getServerInfo, }, metrics: { diff --git a/src/core/server/metrics/metrics_service.test.ts b/src/core/server/metrics/metrics_service.test.ts index 01a7429745cda5..b3cc06ffca1d21 100644 --- a/src/core/server/metrics/metrics_service.test.ts +++ b/src/core/server/metrics/metrics_service.test.ts @@ -30,7 +30,7 @@ const testInterval = 100; const dummyMetrics = { metricA: 'value', metricB: 'otherValue' }; describe('MetricsService', () => { - const httpMock = httpServiceMock.createSetupContract(); + const httpMock = httpServiceMock.createInternalSetupContract(); let metricsService: MetricsService; beforeEach(() => { diff --git a/src/core/server/metrics/ops_metrics_collector.test.ts b/src/core/server/metrics/ops_metrics_collector.test.ts index 559588db60a425..9e76895b14578d 100644 --- a/src/core/server/metrics/ops_metrics_collector.test.ts +++ b/src/core/server/metrics/ops_metrics_collector.test.ts @@ -29,7 +29,7 @@ describe('OpsMetricsCollector', () => { let collector: OpsMetricsCollector; beforeEach(() => { - const hapiServer = httpServiceMock.createSetupContract().server; + const hapiServer = httpServiceMock.createInternalSetupContract().server; collector = new OpsMetricsCollector(hapiServer); mockOsCollector.collect.mockResolvedValue('osMetrics'); diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index b6e9ffef6f3f13..f3ae5462f16316 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -19,7 +19,6 @@ import { of } from 'rxjs'; import { duration } from 'moment'; import { PluginInitializerContext, CoreSetup, CoreStart, StartServicesAccessor } from '.'; -import { CspConfig } from './csp'; import { loggingServiceMock } from './logging/logging_service.mock'; import { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; import { httpServiceMock } from './http/http_service.mock'; @@ -112,26 +111,10 @@ function createCoreSetupMock({ pluginStartDeps?: object; pluginStartContract?: any; } = {}) { - const httpService = httpServiceMock.createSetupContract(); const httpMock: jest.Mocked = { - createCookieSessionStorageFactory: httpService.createCookieSessionStorageFactory, - registerOnPreAuth: httpService.registerOnPreAuth, - registerAuth: httpService.registerAuth, - registerOnPostAuth: httpService.registerOnPostAuth, - registerOnPreResponse: httpService.registerOnPreResponse, - basePath: httpService.basePath, - csp: CspConfig.DEFAULT, - isTlsEnabled: httpService.isTlsEnabled, - createRouter: jest.fn(), - registerRouteHandlerContext: jest.fn(), - auth: { - get: httpService.auth.get, - isAuthenticated: httpService.auth.isAuthenticated, - }, + ...httpServiceMock.createSetupContract(), resources: httpResourcesMock.createRegistrar(), - getServerInfo: httpService.getServerInfo, }; - httpMock.createRouter.mockImplementation(() => httpService.createRouter('')); const uiSettingsMock = { register: uiSettingsServiceMock.createSetupContract().register, @@ -159,6 +142,7 @@ function createCoreStartMock() { const mock: MockedKeys = { capabilities: capabilitiesServiceMock.createStartContract(), elasticsearch: elasticsearchServiceMock.createStart(), + http: httpServiceMock.createStartContract(), savedObjects: savedObjectsServiceMock.createStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), }; @@ -171,7 +155,7 @@ function createInternalCoreSetupMock() { capabilities: capabilitiesServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), elasticsearch: elasticsearchServiceMock.createInternalSetup(), - http: httpServiceMock.createSetupContract(), + http: httpServiceMock.createInternalSetupContract(), metrics: metricsServiceMock.createInternalSetupContract(), savedObjects: savedObjectsServiceMock.createInternalSetupContract(), status: statusServiceMock.createInternalSetupContract(), @@ -187,6 +171,7 @@ function createInternalCoreStartMock() { const startDeps: InternalCoreStart = { capabilities: capabilitiesServiceMock.createStartContract(), elasticsearch: elasticsearchServiceMock.createStart(), + http: httpServiceMock.createInternalStartContract(), savedObjects: savedObjectsServiceMock.createInternalStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), }; diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index f0db3a25e313d9..31e36db49223a7 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -164,7 +164,6 @@ export function createPluginSetupContext( basePath: deps.http.basePath, auth: { get: deps.http.auth.get, isAuthenticated: deps.http.auth.isAuthenticated }, csp: deps.http.csp, - isTlsEnabled: deps.http.isTlsEnabled, getServerInfo: deps.http.getServerInfo, }, metrics: { @@ -211,6 +210,11 @@ export function createPluginStartContext( resolveCapabilities: deps.capabilities.resolveCapabilities, }, elasticsearch: deps.elasticsearch, + http: { + auth: deps.http.auth, + basePath: deps.http.basePath, + getServerInfo: deps.http.getServerInfo, + }, savedObjects: { getScopedClient: deps.savedObjects.getScopedClient, createInternalRepository: deps.savedObjects.createInternalRepository, diff --git a/src/core/server/rendering/__mocks__/params.ts b/src/core/server/rendering/__mocks__/params.ts index 3e668b3f26ab59..ce2eea119d1bb3 100644 --- a/src/core/server/rendering/__mocks__/params.ts +++ b/src/core/server/rendering/__mocks__/params.ts @@ -23,7 +23,7 @@ import { pluginServiceMock } from '../../plugins/plugins_service.mock'; import { legacyServiceMock } from '../../legacy/legacy_service.mock'; const context = mockCoreContext.create(); -const http = httpServiceMock.createSetupContract(); +const http = httpServiceMock.createInternalSetupContract(); const uiPlugins = pluginServiceMock.createUiPlugins(); const legacyPlugins = legacyServiceMock.createDiscoverPlugins(); diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index 32485f461f59b9..5da2235828b5c8 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -47,6 +47,7 @@ describe('getSortedObjectsForExport()', () => { id: '2', type: 'search', attributes: {}, + score: 1, references: [ { name: 'name', @@ -59,6 +60,7 @@ describe('getSortedObjectsForExport()', () => { id: '1', type: 'index-pattern', attributes: {}, + score: 1, references: [], }, ], @@ -133,6 +135,7 @@ describe('getSortedObjectsForExport()', () => { id: '2', type: 'search', attributes: {}, + score: 1, references: [ { name: 'name', @@ -145,6 +148,7 @@ describe('getSortedObjectsForExport()', () => { id: '1', type: 'index-pattern', attributes: {}, + score: 1, references: [], }, ], @@ -192,6 +196,7 @@ describe('getSortedObjectsForExport()', () => { id: '2', type: 'search', attributes: {}, + score: 1, references: [ { name: 'name', @@ -204,6 +209,7 @@ describe('getSortedObjectsForExport()', () => { id: '1', type: 'index-pattern', attributes: {}, + score: 1, references: [], }, ], @@ -279,6 +285,7 @@ describe('getSortedObjectsForExport()', () => { id: '2', type: 'search', attributes: {}, + score: 1, references: [ { name: 'name', @@ -291,6 +298,7 @@ describe('getSortedObjectsForExport()', () => { id: '1', type: 'index-pattern', attributes: {}, + score: 1, references: [], }, ], @@ -366,6 +374,7 @@ describe('getSortedObjectsForExport()', () => { id: '2', type: 'search', attributes: {}, + score: 1, references: [ { type: 'index-pattern', @@ -378,6 +387,7 @@ describe('getSortedObjectsForExport()', () => { id: '1', type: 'index-pattern', attributes: {}, + score: 1, references: [], }, ], @@ -405,6 +415,7 @@ describe('getSortedObjectsForExport()', () => { attributes: { name: 'baz', }, + score: 1, references: [], }, { @@ -413,6 +424,7 @@ describe('getSortedObjectsForExport()', () => { attributes: { name: 'foo', }, + score: 1, references: [], }, { @@ -421,6 +433,7 @@ describe('getSortedObjectsForExport()', () => { attributes: { name: 'bar', }, + score: 1, references: [], }, ], diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index cafaa5a3147db3..6e985c25aeaef9 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -116,8 +116,11 @@ async function fetchObjectsToExport({ } // sorts server-side by _id, since it's only available in fielddata - return findResponse.saved_objects.sort((a: SavedObject, b: SavedObject) => - a.id > b.id ? 1 : -1 + return ( + findResponse.saved_objects + // exclude the find-specific `score` property from the exported objects + .map(({ score, ...obj }) => obj) + .sort((a: SavedObject, b: SavedObject) => (a.id > b.id ? 1 : -1)) ); } else { throw Boom.badRequest('Either `type` or `objects` are required.'); diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts index 31bda1d6b9cbd2..33e12dd4e517dd 100644 --- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -79,6 +79,7 @@ describe('GET /api/saved_objects/_find', () => { timeFieldName: '@timestamp', notExpandable: true, attributes: {}, + score: 1, references: [], }, { @@ -88,6 +89,7 @@ describe('GET /api/saved_objects/_find', () => { timeFieldName: '@timestamp', notExpandable: true, attributes: {}, + score: 1, references: [], }, ], diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index 9fba2728003d23..e8b2cf0b583b1b 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -61,7 +61,7 @@ describe('SavedObjectsService', () => { const createSetupDeps = () => { const elasticsearchMock = elasticsearchServiceMock.createInternalSetup(); return { - http: httpServiceMock.createSetupContract(), + http: httpServiceMock.createInternalSetupContract(), elasticsearch: elasticsearchMock, legacyPlugins: legacyServiceMock.createDiscoverPlugins(), }; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index d631ef9cb353cc..ea749235cbb41b 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -1939,7 +1939,7 @@ describe('SavedObjectsRepository', () => { { _index: '.kibana', _id: `${namespace ? `${namespace}:` : ''}config:6.0.0-alpha1`, - _score: 1, + _score: 2, ...mockVersionProps, _source: { namespace, @@ -1954,7 +1954,7 @@ describe('SavedObjectsRepository', () => { { _index: '.kibana', _id: `${namespace ? `${namespace}:` : ''}index-pattern:stocks-*`, - _score: 1, + _score: 3, ...mockVersionProps, _source: { namespace, @@ -1970,7 +1970,7 @@ describe('SavedObjectsRepository', () => { { _index: '.kibana', _id: `${NAMESPACE_AGNOSTIC_TYPE}:something`, - _score: 1, + _score: 4, ...mockVersionProps, _source: { type: NAMESPACE_AGNOSTIC_TYPE, @@ -2131,6 +2131,7 @@ describe('SavedObjectsRepository', () => { type: doc._source.type, ...mockTimestampFields, version: mockVersion, + score: doc._score, attributes: doc._source[doc._source.type], references: [], }); @@ -2153,6 +2154,7 @@ describe('SavedObjectsRepository', () => { type: doc._source.type, ...mockTimestampFields, version: mockVersion, + score: doc._score, attributes: doc._source[doc._source.type], references: [], }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 03538f23948459..40c5282a77e499 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -41,6 +41,7 @@ import { SavedObjectsBulkUpdateResponse, SavedObjectsCreateOptions, SavedObjectsFindResponse, + SavedObjectsFindResult, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, SavedObjectsBulkUpdateObject, @@ -674,8 +675,11 @@ export class SavedObjectsRepository { page, per_page: perPage, total: response.hits.total, - saved_objects: response.hits.hits.map((hit: SavedObjectsRawDoc) => - this._rawToSavedObject(hit) + saved_objects: response.hits.hits.map( + (hit: SavedObjectsRawDoc): SavedObjectsFindResult => ({ + ...this._rawToSavedObject(hit), + score: (hit as any)._score, + }) ), }; } diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 8780f07cc3091b..e15a92c92772f3 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -79,6 +79,17 @@ export interface SavedObjectsBulkResponse { saved_objects: Array>; } +/** + * + * @public + */ +export interface SavedObjectsFindResult extends SavedObject { + /** + * The Elasticsearch `_score` of this result. + */ + score: number; +} + /** * Return type of the Saved Objects `find()` method. * @@ -88,7 +99,7 @@ export interface SavedObjectsBulkResponse { * @public */ export interface SavedObjectsFindResponse { - saved_objects: Array>; + saved_objects: Array>; total: number; per_page: number; page: number; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index ecfa09fbd37f39..9dc3ac9b94d96d 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -657,6 +657,8 @@ export interface CoreStart { // (undocumented) elasticsearch: ElasticsearchServiceStart; // (undocumented) + http: HttpServiceStart; + // (undocumented) savedObjects: SavedObjectsServiceStart; // (undocumented) uiSettings: UiSettingsServiceStart; @@ -905,6 +907,12 @@ export type Headers = { [header: string]: string | string[] | undefined; }; +// @public (undocumented) +export interface HttpAuth { + get: GetAuthState; + isAuthenticated: IsAuthenticated; +} + // @public export interface HttpResources { register: (route: RouteConfig, handler: HttpResourcesRequestHandler) => void; @@ -948,17 +956,13 @@ export interface HttpServerInfo { // @public export interface HttpServiceSetup { - // (undocumented) - auth: { - get: GetAuthState; - isAuthenticated: IsAuthenticated; - }; + // @deprecated + auth: HttpAuth; basePath: IBasePath; createCookieSessionStorageFactory: (cookieOptions: SessionStorageCookieOptions) => Promise>; createRouter: () => IRouter; csp: ICspConfig; getServerInfo: () => HttpServerInfo; - isTlsEnabled: boolean; registerAuth: (handler: AuthenticationHandler) => void; registerOnPostAuth: (handler: OnPostAuthHandler) => void; registerOnPreAuth: (handler: OnPreAuthHandler) => void; @@ -968,7 +972,9 @@ export interface HttpServiceSetup { // @public (undocumented) export interface HttpServiceStart { - isListening: (port: number) => boolean; + auth: HttpAuth; + basePath: IBasePath; + getServerInfo: () => HttpServerInfo; } // @public @@ -2039,11 +2045,16 @@ export interface SavedObjectsFindResponse { // (undocumented) per_page: number; // (undocumented) - saved_objects: Array>; + saved_objects: Array>; // (undocumented) total: number; } +// @public (undocumented) +export interface SavedObjectsFindResult extends SavedObject { + score: number; +} + // @public export interface SavedObjectsImportConflictError { // (undocumented) diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 6ca580083648f8..ae1a02cf71b886 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -202,10 +202,12 @@ export class Server { }); const capabilitiesStart = this.capabilities.start(); const uiSettingsStart = await this.uiSettings.start(); + const httpStart = this.http.getStartContract(); this.coreStart = { capabilities: capabilitiesStart, elasticsearch: elasticsearchStart, + http: httpStart, savedObjects: savedObjectsStart, uiSettings: uiSettingsStart, }; @@ -221,6 +223,7 @@ export class Server { }); await this.http.start(); + await this.rendering.start({ legacy: this.legacy, }); diff --git a/src/core/server/ui_settings/ui_settings_service.test.ts b/src/core/server/ui_settings/ui_settings_service.test.ts index ebcb0cf1d762fb..096ca347e6f4b9 100644 --- a/src/core/server/ui_settings/ui_settings_service.test.ts +++ b/src/core/server/ui_settings/ui_settings_service.test.ts @@ -49,7 +49,7 @@ describe('uiSettings', () => { beforeEach(() => { const coreContext = mockCoreContext.create(); coreContext.configService.atPath.mockReturnValue(new BehaviorSubject({ overrides })); - const httpSetup = httpServiceMock.createSetupContract(); + const httpSetup = httpServiceMock.createInternalSetupContract(); const savedObjectsSetup = savedObjectsServiceMock.createInternalSetupContract(); setupDeps = { http: httpSetup, savedObjects: savedObjectsSetup }; savedObjectsClient = savedObjectsClientMock.create(); diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index d1fb544de733c3..745a3d1f0c8307 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -235,6 +235,8 @@ kibana_vars=( xpack.security.session.lifespan xpack.security.loginAssistanceMessage xpack.security.loginHelp + xpack.spaces.enabled + xpack.spaces.maxSpaces telemetry.allowChangingOptInStatus telemetry.enabled telemetry.optIn diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 2f785896da8d55..85bfd4a7a4d260 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -22,7 +22,6 @@ export const storybookAliases = { canvas: 'x-pack/plugins/canvas/scripts/storybook_new.js', codeeditor: 'src/plugins/kibana_react/public/code_editor/scripts/storybook.ts', dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/scripts/storybook.js', - drilldowns: 'x-pack/plugins/drilldowns/scripts/storybook.js', embeddable: 'src/plugins/embeddable/scripts/storybook.js', infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js', security_solution: 'x-pack/plugins/security_solution/scripts/storybook.js', diff --git a/src/es_archiver/es_archiver.ts b/src/es_archiver/es_archiver.ts index f36cbb3f516b94..e335652195b863 100644 --- a/src/es_archiver/es_archiver.ts +++ b/src/es_archiver/es_archiver.ts @@ -49,7 +49,7 @@ export class EsArchiver { this.client = client; this.dataDir = dataDir; this.log = log; - this.kbnClient = new KbnClient(log, [kibanaUrl]); + this.kbnClient = new KbnClient(log, { url: kibanaUrl }); } /** diff --git a/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/worker.js b/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/worker.js index 1cc626cfa668b7..bd5932e88b5e9e 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/worker.js +++ b/src/plugins/console/public/application/models/legacy_core_editor/mode/worker/worker.js @@ -1,5 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* @notice + * + * This product includes code that is based on Ace editor, which was available + * under a "BSD" license. + * + * Distributed under the BSD license: + * + * Copyright (c) 2010, Ajax.org B.V. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the name of Ajax.org B.V. nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /* eslint-disable */ -/* +/* This file is loaded up as a blob by Brace to hand to Ace to load as Jsonp (hence the redefining of everything). It is based on the javascript mode from the brace distro. @@ -197,7 +249,6 @@ ace.define('ace/lib/oop', ['require', 'exports', 'module'], function ( acequire, exports ) { - (exports.inherits = function (ctor, superCtor) { (ctor.super_ = superCtor), (ctor.prototype = Object.create(superCtor.prototype, { @@ -221,7 +272,6 @@ ace.define('ace/range', ['require', 'exports', 'module'], function ( acequire, exports ) { - let comparePoints = function (p1, p2) { return p1.row - p2.row || p1.column - p2.column; }, @@ -426,7 +476,6 @@ ace.define('ace/apply_delta', ['require', 'exports', 'module'], function ( acequire, exports ) { - exports.applyDelta = function (docLines, delta) { let row = delta.start.row, startColumn = delta.start.column, @@ -467,7 +516,6 @@ ace.define( 'ace/lib/event_emitter', ['require', 'exports', 'module'], function (acequire, exports) { - let EventEmitter = {}, stopPropagation = function () { this.propagationStopped = !0; @@ -579,7 +627,6 @@ ace.define( 'ace/anchor', ['require', 'exports', 'module', 'ace/lib/oop', 'ace/lib/event_emitter'], function (acequire, exports) { - let oop = acequire('./lib/oop'), EventEmitter = acequire('./lib/event_emitter').EventEmitter, Anchor = (exports.Anchor = function (doc, row, column) { @@ -696,7 +743,6 @@ ace.define( 'ace/anchor', ], function (acequire, exports) { - let oop = acequire('./lib/oop'), applyDelta = acequire('./apply_delta').applyDelta, EventEmitter = acequire('./lib/event_emitter').EventEmitter, @@ -1064,7 +1110,6 @@ ace.define('ace/lib/lang', ['require', 'exports', 'module'], function ( acequire, exports ) { - (exports.last = function (a) { return a[a.length - 1]; }), @@ -1215,7 +1260,6 @@ ace.define( 'ace/lib/lang', ], function (acequire, exports) { - acequire('../range').Range; let Document = acequire('../document').Document, lang = acequire('../lib/lang'), diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 28606b7dd9784c..17968dd0281e6c 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -32,8 +32,10 @@ export { export { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; export { DashboardStart, DashboardUrlGenerator } from './plugin'; -export { DASHBOARD_APP_URL_GENERATOR } from './url_generator'; +export { DASHBOARD_APP_URL_GENERATOR, createDashboardUrlGenerator } from './url_generator'; export { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; +export { SavedObjectDashboard } from './saved_dashboards'; +export { SavedDashboardPanel } from './types'; export function plugin(initializerContext: PluginInitializerContext) { return new DashboardPlugin(initializerContext); diff --git a/src/plugins/dashboard/public/url_generator.ts b/src/plugins/dashboard/public/url_generator.ts index d6805b2d94119e..188de7fd857be8 100644 --- a/src/plugins/dashboard/public/url_generator.ts +++ b/src/plugins/dashboard/public/url_generator.ts @@ -28,6 +28,7 @@ import { import { setStateToKbnUrl } from '../../kibana_utils/public'; import { UrlGeneratorsDefinition, UrlGeneratorState } from '../../share/public'; import { SavedObjectLoader } from '../../saved_objects/public'; +import { ViewMode } from '../../embeddable/public'; export const STATE_STORAGE_KEY = '_a'; export const GLOBAL_STATE_STORAGE_KEY = '_g'; @@ -73,6 +74,11 @@ export type DashboardAppLinkGeneratorState = UrlGeneratorState<{ * true is default */ preserveSavedFilters?: boolean; + + /** + * View mode of the dashboard. + */ + viewMode?: ViewMode; }>; export const createDashboardUrlGenerator = ( @@ -123,6 +129,7 @@ export const createDashboardUrlGenerator = ( cleanEmptyKeys({ query: state.query, filters: filters?.filter((f) => !esFilters.isFilterPinned(f)), + viewMode: state.viewMode, }), { useHash }, `${appBasePath}#/${hash}` diff --git a/src/plugins/embeddable/docs/README.md b/src/plugins/embeddable/docs/README.md index 1b6c7be13b1d42..ce5e76d54a0469 100644 --- a/src/plugins/embeddable/docs/README.md +++ b/src/plugins/embeddable/docs/README.md @@ -2,4 +2,5 @@ ## Reference -- [Embeddable containers and inherited input state](./containers_and_inherited_state.md) +- [Input and output state](./input_and_output_state.md) +- [Common mistakes with embeddable containers and inherited input state](./containers_and_inherited_state.md) diff --git a/src/plugins/embeddable/docs/containers_and_inherited_state.md b/src/plugins/embeddable/docs/containers_and_inherited_state.md index c950bef96002ae..35e399f89c1313 100644 --- a/src/plugins/embeddable/docs/containers_and_inherited_state.md +++ b/src/plugins/embeddable/docs/containers_and_inherited_state.md @@ -1,4 +1,4 @@ -## Embeddable containers and inherited input state +## Common mistakes with embeddable containers and inherited input state `updateInput` is typed as `updateInput(input: Partial)`. Notice it's _partial_. This is to support the use case of inherited state when an embeddable is inside a container. diff --git a/src/plugins/embeddable/docs/input_and_output_state.md b/src/plugins/embeddable/docs/input_and_output_state.md new file mode 100644 index 00000000000000..810dc72664f96d --- /dev/null +++ b/src/plugins/embeddable/docs/input_and_output_state.md @@ -0,0 +1,282 @@ +## Input and output state + +### What's the difference? + +Input vs Output State + +| Input | Output | +| ----------- | ----------- | +| Public, on the IEmbeddable interface. `embeddable.updateInput(changedInput)` | Protected inside the Embeddable class. `this.updateOutput(changedOutput)` | +| Serializable representation of the embeddable | Does not need to be serializable | +| Can be updated throughout the lifecycle of an Embeddable | Often derived from input state | + +Non-real examples to showcase the difference: + +| Input | Output | +| ----------- | ----------- | +| savedObjectId | savedObjectAttributes | +| esQueryRequest | esQueryResponse | +| props | renderComplete | + +### Types of input state + +#### Inherited input state + +The only reason we have different types of input state is to support embeddable containers, and children embeddables _inheriting_ state from the container. +For example, when the dashboard time range changes, so does +the time range of all children embeddables. Dashboard passes down time range as _inherited_ input state. From the viewpoint of the child Embeddable, +time range is just input state. It doesn't care where it gets this data from. + + +For example, imagine a container with this input: + +```js +{ + gridData: {...}, + timeRange: 'now-15m to now', + + // Every embeddable container has a panels mapping. It's how the base container class manages common changes like children being + // added, removed or edited. + panels: { + ['1']: { + // `type` is used to grab the right embeddable factory. Every PanelState must specify one. + type: 'clock', + + // `explicitInput` is combined with `inheritedInput` to create `childInput`, and is use like: + // `embeddableFactories.get(type).create(childInput)`. + explicitInput: { + + // All explicitInput is required to have an id. This is used as a way for the + // embeddable to know where it exists in the panels array if it's living in a container. + // Note, this is NOT THE SAVED OBJECT ID! Even though it's sometimes used to store the saved object id. + id: '1', + } + } + } +} +``` + +That could result in the following input being passed to a child: + +```js +{ + timeRange: 'now-15m to now', + id: '1', +} +``` + +Notice that `gridData` is not passed down, but `timeRange` is. What ends up as _inherited_ state, that is passed down to a child, is up to the specific +implementation of a container and +determined by the abstract function `Container.getInheritedInput()` + +#### Overridding inherited input + +We wanted to support _overriding_ this inherited state, to support the "Per panel time range" feature. The _inherited_ `timeRange` input can be +overridden by the _explicit_ `timeRange` input. + +Take this example dashboard container input: + +```js +{ + gridData: {...}, + timeRange: 'now-15m to now', + panels: { + ['1']: { + type: 'clock', + explicitInput: { + timeRange: 'now-30m to now', + id: '1', + } + }, + ['2']: { + type: 'clock', + explicitInput: { + id: '2', + } + }, +} +``` + +The first child embeddable will get passed input state: + +```js +{ + timeRange: 'now-30m to now', + id: '1', +} +``` + +This override wouldn't affect other children, so the second child would receive: + +```js +{ + timeRange: 'now-15m to now', + id: '2', +} +``` + +#### EmbeddableInput.id and some technical debt + +Above I said: + +> From the viewpoint of the child Embeddable, +> time range is just input state. It doesn't care where it gets this data from. + +and this is mostly true, however, the primary reason EmbeddableInput.id exists is to support the +case where the custom time range badge action needs to look up a child's explicit input on the +parent. It does this to determine whether or not to show the badge. The logic is something like: + +```ts + // If there is no explicit input defined on the parent then this embeddable inherits the + // time range from whatever the time range of the parent is. + return parent.getInput().panels[embeddable.id].explicitInput.timeRange === undefined; +``` + +It doesn't just compare the timeRange input on the parent (`embeddable.parent?.getInput().timeRange` )because even if they happen to match, +we still want the badge showing to indicate the time range is "locked" on this particular panel. + +Note that `parent` can be retrieved from either `embeddabble.parent` or `embeddable.getRoot()`. The +`getRoot` variety will walk up to find the root parent, even though we have no tested or used +nested containers, it is theoretically possible. + +This EmbeddableInput.id parameter is marked as required on the `EmbeddableInput` interface, even though it's only used +when an embeddable is inside a parent. There is also no +typescript safety to ensure the id matches the panel id in the parents json: + +```js + ['2']: { + type: 'clock', + explicitInput: { + id: '3', // No! Should be 2! + } + }, +``` + +It should probably be something that the parent passes down to the child specifically, based on the panel mapping key, +and renamed to something like `panelKeyInParent`. + +Note that this has nothing to do with a saved object id, even though in dashboard app, the saved object happens to be +used as the dashboard container id. Another reason this should probably not be required for embeddables not +inside containers. + +#### A container can pass down any information to the children + +It doesn't have to be part of it's own input. It's possible for a container input like: + + +```js +{ + timeRange: 'now-15m to now', + panels: { + ['1']: { + type: 'clock', + explicitInput: { + timeRange: 'now-30m to now', + id: '1', + } + } +} +``` + +to pass down this input: + +```js +{ + timeRange: 'now-30m to now', + id: '1', + zed: 'bar', // <-- Where did this come from?? +} +``` + +I don't have a realistic use case for this, just noting it's possible in any containers implementation of `getInheritedInput`. Note this is still considered +inherited input because it's coming from the container. + +#### Explicit input stored on behalf of the container + +It's possible for a container to store explicit input state on behalf of an embeddable, without knowing what that state is. For example, a container could +have input state like: + +```js +{ + timeRange: 'now-15m to now', + panels: { + ['1']: { + type: 'clock', + explicitInput: { + display: 'analog', + id: '1', + } + } +} +``` + +And what gets passed to the child is: + +```js +{ + timeRange: 'now-15m to now', + id: '1', + display: 'analog' +} +``` + +even if a container has no idea about this `clock` embeddable implementation, nor this `explicitInput.display` field. + +There are two ways for this kind of state to end up in `panels[id].explicitInput`. + +1. `ClockEmbeddableFactory.getExplicitInput` returns it. +2. `ClockEmbeddableFactory.getDefaultInput` returns it. (This function is largely unused. We may be able to get rid of it.) +3. Someone called `embeddable.updateInput({ display: 'analog' })`, when the embeddable is a child in a container. + +#### Containers can pass down too much information + +Lets say our container state is: + +```js +{ + timeRange: 'now-15m to now', + panels: { + ['1']: { + type: 'helloWorld', + explicitInput: { + id: '1', + } + } +} +``` + +What gets passed to the child is: + +```js +{ + timeRange: 'now-15m to now', + id: '1', +} +``` + +It doesn't matter if the embeddable does not require, nor use, `timeRange`. The container passes down inherited input state to every child. +This could present problems with trying to figure out which embeddables support +different types of actions. For example, it'd be great if "Customize time range" action only showed up on embeddables that actually did something +with the `timeRange`. You can't check at runtime whether `input.timeRange === undefined` to do so though, because it will be passed in by the container +regardless. + + +#### Tech debt warnings + +`EmbeddableFactory.getExplicitInput` was intended as a way for an embeddable to retrieve input state it needs, that will not +be provided by a container. However, an embeddable won't know where it will be rendered, so how will the factory know which +required data to ask from the user and which will be inherited from the container? I believe `getDefaultInput` was meant to solve this. +`getDefaultInput` would provide default values, only if the container didn't supply them through inheritance. Explicit input would +always provide these values, and would always be stored in a containers `panel[id].explicitInput`, even if the container _did_ provide +them. + +There are no real life examples showcasing this, it may not even be really needed by current use cases. Containers were built as an abstraction, with +the thinking being that it would support any type of rendering of child embeddables - whether in a "snap to grid" style like dashboard, +or in a free form layout like canvas. + +The only real implementation of a container in production code at the time this is written is Dashboard however, with no plans to migrate +Canvas over to use it (this was the original impetus for an abstraction). The container code is quite complicated with child management, +so it makes creating a new container very easy, as you can see in the developer examples of containers. But, it's possible this layer was + an over abstraction without a real prod use case (I can say that because I wrote it, I'm only insulting myself!) :). + +Be sure to read [Common mistakes with embeddable containers and inherited input state](./containers_and_inherited_state.md) next! \ No newline at end of file diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 8ba7be7880a7bd..7b66f29cc27267 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -81,7 +81,7 @@ function renderNotifications( if (tooltip) { badge = ( - + {badge} ); diff --git a/src/plugins/es_ui_shared/public/console_lang/ace/modes/x_json/worker/x_json.ace.worker.js b/src/plugins/es_ui_shared/public/console_lang/ace/modes/x_json/worker/x_json.ace.worker.js index 69a8cc86f1f736..53cdb5885c730e 100644 --- a/src/plugins/es_ui_shared/public/console_lang/ace/modes/x_json/worker/x_json.ace.worker.js +++ b/src/plugins/es_ui_shared/public/console_lang/ace/modes/x_json/worker/x_json.ace.worker.js @@ -1,3 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* @notice + * + * This product includes code that is based on Ace editor, which was available + * under a "BSD" license. + * + * Distributed under the BSD license: + * + * Copyright (c) 2010, Ajax.org B.V. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the name of Ajax.org B.V. nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + /* eslint-disable */ /* This file is loaded up as a blob by Brace to hand to Ace to load as Jsonp diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/README.md b/src/plugins/es_ui_shared/public/forms/form_wizard/README.md new file mode 100644 index 00000000000000..56c792b89049b7 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/README.md @@ -0,0 +1,45 @@ +# FormWizard + +The `` and `` components lets us declare form wizard in a declarative way. It works hand in hand with the `MultiContent` explained above to make building form wizards a breeze. 😊 + +It takes care of enabling, disabling the `` steps as well as the "Back" and "Next" button. + +Let's see it through an example + +```js +const MyForm = () => { + return ( + + defaultValue={wizardDefaultValue} // The MultiContent default value as explained above + onSave={onSaveTemplate} // A handler that will receive the multi-content data + isEditing={isEditing} // A boolean that will indicate if all steps are already "completed" and thus valid or if we need to complete them in order + isSaving={isSaving} // A boolean to show a "Saving..." text on the button on the last step + apiError={apiError} // Any API error to display on top of wizard + texts={i18nTexts} // i18n translations for the nav button. + > + +
+ Here you can put anything... but you probably want to put a Container from the + MultiContent example above. +
+
+ + +
+ Here you can put anything... but you probably want to put a Container from the + MultiContent example above. +
+
+ + +
+ Here you can put anything... but you probably want to put a Container from the + MultiContent example above. +
+
+
+ ); +}; +``` + +That's all we need to build a multi-step form wizard, making sure the data is cached when switching steps. \ No newline at end of file diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx new file mode 100644 index 00000000000000..cdb332e9e9130d --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx @@ -0,0 +1,139 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { EuiStepsHorizontal, EuiSpacer } from '@elastic/eui'; + +import { + FormWizardProvider, + FormWizardConsumer, + Props as ProviderProps, +} from './form_wizard_context'; +import { FormWizardNav, NavTexts } from './form_wizard_nav'; + +interface Props extends ProviderProps { + isSaving?: boolean; + apiError: JSX.Element | null; + texts?: Partial; +} + +export function FormWizard({ + texts, + defaultActiveStep, + defaultValue, + apiError, + isEditing, + isSaving, + onSave, + onChange, + children, +}: Props) { + return ( + + defaultValue={defaultValue} + isEditing={isEditing} + onSave={onSave} + onChange={onChange} + defaultActiveStep={defaultActiveStep} + > + + {({ activeStepIndex, lastStep, steps, isCurrentStepValid, navigateToStep }) => { + const stepsRequiredArray = Object.values(steps).map( + (step) => Boolean(step.isRequired) && step.isComplete === false + ); + + const getIsStepDisabled = (stepIndex: number) => { + // Disable all steps when the current step is invalid + if (stepIndex !== activeStepIndex && isCurrentStepValid === false) { + return true; + } + + let isDisabled = false; + + if (stepIndex > activeStepIndex + 1) { + /** + * Rule explained: + * - all the previous steps are always enabled (we can go back anytime) + * - the next step is also always enabled (it acts as the "Next" button) + * - for the rest, the step is disabled if any of the previous step (_greater_ than the current + * active step), is marked as isRequired **AND** has not been completed. + */ + isDisabled = stepsRequiredArray.reduce((acc, isRequired, i) => { + if (acc === true || i <= activeStepIndex || i >= stepIndex) { + return acc; + } + return Boolean(isRequired); + }, false); + } + + return isDisabled; + }; + + const euiSteps = Object.values(steps).map(({ index, label }) => { + return { + title: label, + isComplete: activeStepIndex > index, + isSelected: activeStepIndex === index, + disabled: getIsStepDisabled(index), + onClick: () => navigateToStep(index), + }; + }); + + const onBack = () => { + const prevStep = activeStepIndex - 1; + navigateToStep(prevStep); + }; + + const onNext = () => { + const nextStep = activeStepIndex + 1; + navigateToStep(nextStep); + }; + + return ( + <> + {/* Horizontal Steps indicator */} + + + + + {/* Any possible API error when saving/updating */} + {apiError} + + {/* Active step content */} + {children} + + + + {/* Button navigation */} + + + ); + }} + + + ); +} diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_context.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_context.tsx new file mode 100644 index 00000000000000..5667220881df2c --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_context.tsx @@ -0,0 +1,173 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, createContext, useContext, useCallback } from 'react'; + +import { WithMultiContent, useMultiContentContext, HookProps } from '../multi_content'; + +export interface Props { + onSave: (data: T) => void | Promise; + children: JSX.Element | JSX.Element[]; + isEditing?: boolean; + defaultActiveStep?: number; + defaultValue?: HookProps['defaultValue']; + onChange?: HookProps['onChange']; +} + +interface State { + activeStepIndex: number; + steps: Steps; +} + +export interface Step { + id: string; + index: number; + label: string; + isRequired: boolean; + isComplete: boolean; +} + +export interface Steps { + [stepId: string]: Step; +} + +export interface Context extends State { + activeStepId: Id; + lastStep: number; + isCurrentStepValid: boolean | undefined; + navigateToStep: (stepId: number | Id) => void; + addStep: (id: Id, label: string, isRequired?: boolean) => void; +} + +const formWizardContext = createContext({} as Context); + +export const FormWizardProvider = WithMultiContent>(function FormWizardProvider< + T extends object = { [key: string]: any } +>({ children, defaultActiveStep = 0, isEditing, onSave }: Props) { + const { getData, validate, validation } = useMultiContentContext(); + + const [state, setState] = useState({ + activeStepIndex: defaultActiveStep, + steps: {}, + }); + + const activeStepId = state.steps[state.activeStepIndex]?.id; + const lastStep = Object.keys(state.steps).length - 1; + const isCurrentStepValid = validation.contents[activeStepId as keyof T]; + + const addStep = useCallback( + (id: string, label: string, isRequired = false) => { + setState((prev) => { + const index = Object.keys(prev.steps).length; + + return { + ...prev, + steps: { + ...prev.steps, + [index]: { id, index, label, isRequired, isComplete: isEditing ?? false }, + }, + }; + }); + }, + [isEditing] + ); + + /** + * Get the step index from a step id. + */ + const getStepIndex = useCallback( + (stepId: number | string) => { + if (typeof stepId === 'number') { + return stepId; + } + + // We provided a string stepId, we need to find the corresponding index + const targetStep: Step | undefined = Object.values(state.steps).find( + (_step) => _step.id === stepId + ); + if (!targetStep) { + throw new Error(`Can't navigate to step "${stepId}" as there are no step with that ID.`); + } + return targetStep.index; + }, + [state.steps] + ); + + const navigateToStep = useCallback( + async (stepId: number | string) => { + // Before navigating away we validate the active content in the DOM + const isValid = await validate(); + + // If step is not valid do not go any further + if (!isValid) { + return; + } + + const nextStepIndex = getStepIndex(stepId); + + if (nextStepIndex > lastStep) { + // We are on the last step, save the data and don't go any further + onSave(getData() as T); + return; + } + + // Update the active step + setState((prev) => { + const currentStep = prev.steps[prev.activeStepIndex]; + + const nextState = { + ...prev, + activeStepIndex: nextStepIndex, + }; + + if (nextStepIndex > prev.activeStepIndex && !currentStep.isComplete) { + // Mark the current step as completed + nextState.steps[prev.activeStepIndex] = { + ...currentStep, + isComplete: true, + }; + } + + return nextState; + }); + }, + [getStepIndex, validate, onSave, getData] + ); + + const value: Context = { + ...state, + activeStepId, + lastStep, + isCurrentStepValid, + addStep, + navigateToStep, + }; + + return {children}; +}); + +export const FormWizardConsumer = formWizardContext.Consumer; + +export function useFormWizardContext() { + const ctx = useContext(formWizardContext); + if (ctx === undefined) { + throw new Error('useFormWizardContext() must be called within a '); + } + return ctx as Context; +} diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx new file mode 100644 index 00000000000000..3e0e9cf897b5d2 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx @@ -0,0 +1,105 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +interface Props { + activeStepIndex: number; + lastStep: number; + onBack: () => void; + onNext: () => void; + isSaving?: boolean; + isStepValid?: boolean; + texts?: Partial; +} + +export interface NavTexts { + back: string | JSX.Element; + next: string | JSX.Element; + save: string | JSX.Element; + saving: string | JSX.Element; +} + +const DEFAULT_TEXTS = { + back: i18n.translate('esUi.formWizard.backButtonLabel', { defaultMessage: 'Back' }), + next: i18n.translate('esUi.formWizard.nextButtonLabel', { defaultMessage: 'Next' }), + save: i18n.translate('esUi.formWizard.saveButtonLabel', { defaultMessage: 'Save' }), + saving: i18n.translate('esUi.formWizard.savingButtonLabel', { defaultMessage: 'Saving...' }), +}; + +export const FormWizardNav = ({ + activeStepIndex, + lastStep, + isStepValid, + isSaving, + onBack, + onNext, + texts, +}: Props) => { + const isLastStep = activeStepIndex === lastStep; + const labels = { + ...DEFAULT_TEXTS, + ...texts, + }; + + const nextButtonLabel = isLastStep + ? Boolean(isSaving) + ? labels.saving + : labels.save + : labels.next; + + return ( + + + + {/* Back button */} + {activeStepIndex > 0 ? ( + + + {labels.back} + + + ) : null} + + {/* Next button */} + + + {nextButtonLabel} + + + + + + ); +}; diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_step.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_step.tsx new file mode 100644 index 00000000000000..c073c188a6ad6e --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_step.tsx @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect } from 'react'; + +import { useFormWizardContext } from './form_wizard_context'; + +interface Props { + id: string; + label: string; + children: JSX.Element; + isRequired?: boolean; +} + +export const FormWizardStep = ({ id, label, isRequired, children }: Props) => { + const { activeStepId, addStep } = useFormWizardContext(); + + useEffect(() => { + addStep(id, label, isRequired); + }, [id, label, isRequired, addStep]); + + return activeStepId === id ? children : null; +}; diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/index.ts b/src/plugins/es_ui_shared/public/forms/form_wizard/index.ts new file mode 100644 index 00000000000000..b1cb11735a1103 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/index.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { FormWizard } from './form_wizard'; + +export { FormWizardStep } from './form_wizard_step'; + +export { + FormWizardProvider, + FormWizardConsumer, + useFormWizardContext, + Step, + Steps, +} from './form_wizard_context'; + +export { FormWizardNav, NavTexts } from './form_wizard_nav'; diff --git a/src/plugins/es_ui_shared/public/forms/index.ts b/src/plugins/es_ui_shared/public/forms/index.ts new file mode 100644 index 00000000000000..96140c9b461851 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './form_wizard'; + +export * from './multi_content'; diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/README.md b/src/plugins/es_ui_shared/public/forms/multi_content/README.md new file mode 100644 index 00000000000000..08c37c20b5bf6c --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/multi_content/README.md @@ -0,0 +1,157 @@ +# MultiContent + +## The problem + +Building resource creations/edition flows in the UI, that have multiple contents that need to be merged together at the end of the flow and at the same time keeping a reference of each content state, is not trivial. Indeed, when we switch tab or we go to the next step, the old step data needs to be saved somewhere. + +The first thing that comes to mind is: "Ok, I'll lift the state up" and make each step "content" a controlled component (when its value changes, it sends it to the global state and then it receives it back as prop). This works well up to a certain point. What happens if the internal state that the step content works with, is not the same as the outputted state? + +Something like this: + +```js +// StepOne internal state, flat map of fields +const internalState: { + fields: { + ate426jn: { name: 'hello', value: 'world', parent: 'rwtsdg3' }, + rwtsdg3: { name: 'myObject', type: 'object' }, + } +} + +// Outputed data + +const output = { + stepOne: { + myObject: { + hello: 'world' + } + } +} +``` + +We need some sort of serializer to go from the internal state to the output object. If we lift the state up this means that the global state needs to be aware of the intrinsic of the content, leaking implementation details. +This also means that the content **can't be a reusable component** as it depends on an external state to do part of its work (think: the mappings editor). + +This is where `MultiContent` comes into play. It lets us declare `content` objects and automatically saves a snapshot of their content when the component unmounts (which occurs when switching a tab for example). If we navigate back to the tab, the tab content gets its `defaultValue` from that cache state. + +Let see it through a concrete example + +```js +// my_comp_wrapper.tsx + +// Always good to have an interface for our contents +interface MyMultiContent { + contentOne: { myField: string }; + contentTwo: { anotherField: string }; + contentThree: { yetAnotherField: boolean }; +} + +// Each content data will be a slice of the multi-content defaultValue +const defaultValue: MyMultiContent = { + contentOne: { + myField: 'value', + }, + contentTwo: { + anotherField: 'value', + }, + contentThree: { + yetAnotherField: true, + }, +}; +``` + +```js +// my_comp.tsx + +/** + * We wrap our component with the HOC that will provide the and let us use the "useMultiContentContext()" hook + * + * MyComponent connects to the multi-content context and renders each step + * content without worrying about their internal state. + */ +const MyComponent = WithMultiContent(() => { + const { validation, getData, validate } = useMultiContentContext(); + + const totalSteps = 3; + const [currentStep, setCurrentStep] = useState(0); + + const renderContent = () => { + switch (currentStep) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + } + }; + + const onNext = () => { + // Validate the multi content + const isValid = await validate(); + + if (!isValid) { + return; + } + + if (currentStep < totalSteps - 1) { + // Navigate to the next content + setCurrentStep((curentStep += 1)); + } else { + // On last step we need to save so we read the multi-content data + console.log('About to save:', getData()); + } + }; + + return ( + <> + {renderContent()} + + {/* Each content validity is accessible from the `validation.contents` object */} + + Next + + + ); +}); +``` + +```js +// content_one_container.tsx + +// From the good old days of Redux, it is a good practice to separate the connection to the multi-content +// from the UI that is rendered. +const ContentOneContainer = () => { + + // Declare a new content and get its default Value + a handler to update the content in the multi-content + // This will update the "contentOne" slice of the multi-content. + const { defaultValue, updateContent } = useContent('contentOne'); + + return +}; +``` + +```js +// content_one.tsx + +const ContentOne = ({ defaultValue, onChange }) => { + // Use the defaultValue as a starting point for the internal state + const [internalStateValue, setInternalStateValue] = useState(defaultValue.myField); + + useEffect(() => { + // Update the multi content state for this content + onChange({ + isValid: true, // because in this example it is always valid + validate: async () => true, + getData: () => ({ + myField: internalStateValue, + }), + }); + }, [internalStateValue]); + + return ( + setInternalStateValue(e.target.value)} /> + ); +} +``` + +And just like that, `` is a reusable component that gets a `defaultValue` object and an `onChange` handler to communicate any internal state changes. He is responsible to provide a `getData()` handler as part of the `onChange` that will do any necessary serialization and sanitization, and the outside world does not need to know about it. \ No newline at end of file diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/index.ts b/src/plugins/es_ui_shared/public/forms/multi_content/index.ts new file mode 100644 index 00000000000000..a7df0e386d1739 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/multi_content/index.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + MultiContentProvider, + MultiContentConsumer, + useMultiContentContext, + useContent, +} from './multi_content_context'; + +export { useMultiContent, HookProps, Content, MultiContent } from './use_multi_content'; + +export { WithMultiContent } from './with_multi_content'; diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx b/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx new file mode 100644 index 00000000000000..5fbe3d2bbbdd4c --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx @@ -0,0 +1,79 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useCallback, createContext, useContext } from 'react'; + +import { useMultiContent, HookProps, Content, MultiContent } from './use_multi_content'; + +const multiContentContext = createContext>({} as MultiContent); + +interface Props extends HookProps { + children: JSX.Element | JSX.Element[]; +} + +export function MultiContentProvider({ + defaultValue, + onChange, + children, +}: Props) { + const multiContent = useMultiContent({ defaultValue, onChange }); + + return ( + {children} + ); +} + +export const MultiContentConsumer = multiContentContext.Consumer; + +export function useMultiContentContext() { + const ctx = useContext(multiContentContext); + if (Object.keys(ctx).length === 0) { + throw new Error('useMultiContentContext must be used within a '); + } + return ctx as MultiContent; +} + +/** + * Hook to declare a new content and get its defaultValue and a handler to update its content + * + * @param contentId The content id to be added to the "contents" map + */ +export function useContent(contentId: keyof T) { + const { updateContentAt, saveSnapshotAndRemoveContent, getData } = useMultiContentContext(); + + const updateContent = useCallback( + (content: Content) => { + updateContentAt(contentId, content); + }, + [contentId, updateContentAt] + ); + + useEffect(() => { + return () => { + // On unmount: save a snapshot of the data and remove content from our contents map + saveSnapshotAndRemoveContent(contentId); + }; + }, [contentId, saveSnapshotAndRemoveContent]); + + return { + defaultValue: getData()[contentId]!, + updateContent, + getData, + }; +} diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts new file mode 100644 index 00000000000000..0a2c7bb6519593 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts @@ -0,0 +1,215 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState, useCallback, useRef } from 'react'; + +export interface Content { + isValid: boolean | undefined; + validate(): Promise; + getData(): T; +} + +type Contents = { + [K in keyof T]: Content; +}; + +interface Validation { + isValid: boolean | undefined; + contents: { + [K in keyof T]: boolean | undefined; + }; +} + +export interface HookProps { + defaultValue?: T; + onChange?: (output: Content) => void; +} + +export interface MultiContent { + updateContentAt: (id: keyof T, content: Content) => void; + saveSnapshotAndRemoveContent: (id: keyof T) => void; + getData: () => T; + validate: () => Promise; + validation: Validation; +} + +export function useMultiContent({ + defaultValue, + onChange, +}: HookProps): MultiContent { + /** + * Each content validity is kept in this state. When updating a content with "updateContentAt()", we + * update the state validity and trigger a re-render. + */ + const [validation, setValidation] = useState>({ + isValid: true, + contents: {}, + } as Validation); + + /** + * The updated data where a content current data is merged when it unmounts + */ + const [stateData, setStateData] = useState(defaultValue ?? ({} as T)); + + /** + * A map object of all the active content(s) present in the DOM. In a multi step + * form wizard, there is only 1 content at the time in the DOM, but in long vertical + * flow content, multiple content could be present. + * When a content unmounts it will remove itself from this map. + */ + const contents = useRef>({} as Contents); + + const updateContentDataAt = useCallback(function (updatedData: { [key in keyof T]?: any }) { + setStateData((prev) => ({ + ...prev, + ...updatedData, + })); + }, []); + + /** + * Read the multi content data. + */ + const getData = useCallback((): T => { + /** + * If there is one or more active content(s) in the DOM, and it is valid, + * we read its data and merge it into our stateData before returning it. + */ + const activeContentData: Partial = {}; + + for (const [id, _content] of Object.entries(contents.current)) { + if (validation.contents[id as keyof T]) { + const contentData = (_content as Content).getData(); + + // Replace the getData() handler with the cached value + (_content as Content).getData = () => contentData; + + activeContentData[id as keyof T] = contentData; + } + } + + return { + ...stateData, + ...activeContentData, + }; + }, [stateData, validation]); + + const updateContentValidity = useCallback( + (updatedData: { [key in keyof T]?: boolean | undefined }): boolean | undefined => { + let allContentValidity: boolean | undefined; + + setValidation((prev) => { + if ( + Object.entries(updatedData).every( + ([contentId, isValid]) => prev.contents[contentId as keyof T] === isValid + ) + ) { + // No change in validation, nothing to update + allContentValidity = prev.isValid; + return prev; + } + + const nextContentsValidityState = { + ...prev.contents, + ...updatedData, + }; + + allContentValidity = Object.values(nextContentsValidityState).some( + (_isValid) => _isValid === undefined + ) + ? undefined + : Object.values(nextContentsValidityState).every(Boolean); + + return { + isValid: allContentValidity, + contents: nextContentsValidityState, + }; + }); + + return allContentValidity; + }, + [] + ); + + /** + * Validate the multi-content active content(s) in the DOM + */ + const validate = useCallback(async () => { + const updatedValidation = {} as { [key in keyof T]?: boolean | undefined }; + + for (const [id, _content] of Object.entries(contents.current)) { + const isValid = await (_content as Content).validate(); + (_content as Content).validate = async () => isValid; + updatedValidation[id as keyof T] = isValid; + } + + return Boolean(updateContentValidity(updatedValidation)); + }, [updateContentValidity]); + + /** + * Update a content. It replaces the content in our "contents" map and update + * the state validation object. + */ + const updateContentAt = useCallback( + function (contentId: keyof T, content: Content) { + contents.current[contentId] = content; + + const updatedValidity = { [contentId]: content.isValid } as { + [key in keyof T]: boolean | undefined; + }; + const isValid = updateContentValidity(updatedValidity); + + if (onChange !== undefined) { + onChange({ + isValid, + validate, + getData, + }); + } + }, + [updateContentValidity, onChange] + ); + + /** + * When a content unmounts we want to save its current data state so we will be able + * to provide it as "defaultValue" the next time the component is mounted. + */ + const saveSnapshotAndRemoveContent = useCallback( + function (contentId: keyof T) { + if (contents.current[contentId]) { + // Merge the data in our stateData + const updatedData = { + [contentId]: contents.current[contentId].getData(), + } as { [key in keyof T]?: any }; + updateContentDataAt(updatedData); + + // Remove the content from our map + delete contents.current[contentId]; + } + }, + [updateContentDataAt] + ); + + return { + getData, + validate, + validation, + updateContentAt, + saveSnapshotAndRemoveContent, + }; +} diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/with_multi_content.tsx b/src/plugins/es_ui_shared/public/forms/multi_content/with_multi_content.tsx new file mode 100644 index 00000000000000..e69ce4c6fa1454 --- /dev/null +++ b/src/plugins/es_ui_shared/public/forms/multi_content/with_multi_content.tsx @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; + +import { MultiContentProvider } from './multi_content_context'; +import { HookProps } from './use_multi_content'; + +/** + * HOC to wrap a component with the MultiContentProvider + * + * @param Component The component to wrap with the MultiContentProvider + */ +export function WithMultiContent< + P extends object = { [key: string]: any } // The Props for the wrapped component +>(Component: React.FunctionComponent

>) { + return function (props: P & HookProps) { + const { defaultValue, onChange, ...rest } = props; + return ( + defaultValue={defaultValue} onChange={onChange}> + + + ); + }; +} diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index 4ab791289dd886..28baa3d8372f0d 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -17,6 +17,12 @@ * under the License. */ +/** + * Create a namespace for Forms + * In the future, each top level folder should be exported like that to avoid naming collision + */ +import * as Forms from './forms'; + export { JsonEditor, OnJsonEditorUpdateHandler } from './components/json_editor'; export { SectionLoading } from './components/section_loading'; @@ -63,6 +69,8 @@ export { useAuthorizationContext, } from './authorization'; +export { Forms }; + /** dummy plugin, we just want esUiShared to have its own bundle */ export function plugin() { return new (class EsUiSharedPlugin { diff --git a/src/plugins/home/public/application/components/__snapshots__/welcome.test.tsx.snap b/src/plugins/home/public/application/components/__snapshots__/welcome.test.tsx.snap index 1c67f332a12ab1..bdf1f075967cba 100644 --- a/src/plugins/home/public/application/components/__snapshots__/welcome.test.tsx.snap +++ b/src/plugins/home/public/application/components/__snapshots__/welcome.test.tsx.snap @@ -125,7 +125,6 @@ exports[`should render a Welcome screen with the telemetry disclaimer 1`] = ` values={Object {}} /> @@ -224,7 +223,6 @@ exports[`should render a Welcome screen with the telemetry disclaimer when optIn values={Object {}} /> @@ -323,7 +321,6 @@ exports[`should render a Welcome screen with the telemetry disclaimer when optIn values={Object {}} /> diff --git a/src/plugins/home/public/application/components/welcome.test.tsx b/src/plugins/home/public/application/components/welcome.test.tsx index 1332e03ffdc81f..701fab3af75390 100644 --- a/src/plugins/home/public/application/components/welcome.test.tsx +++ b/src/plugins/home/public/application/components/welcome.test.tsx @@ -30,56 +30,39 @@ jest.mock('../kibana_services', () => ({ })); test('should render a Welcome screen with the telemetry disclaimer', () => { - const telemetry = telemetryPluginMock.createSetupContract(); - const component = shallow( - // @ts-ignore - {}} telemetry={telemetry} /> - ); + const telemetry = telemetryPluginMock.createStartContract(); + const component = shallow( {}} telemetry={telemetry} />); expect(component).toMatchSnapshot(); }); test('should render a Welcome screen with the telemetry disclaimer when optIn is true', () => { - const telemetry = telemetryPluginMock.createSetupContract(); + const telemetry = telemetryPluginMock.createStartContract(); telemetry.telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); - const component = shallow( - // @ts-ignore - {}} telemetry={telemetry} /> - ); + const component = shallow( {}} telemetry={telemetry} />); expect(component).toMatchSnapshot(); }); test('should render a Welcome screen with the telemetry disclaimer when optIn is false', () => { - const telemetry = telemetryPluginMock.createSetupContract(); + const telemetry = telemetryPluginMock.createStartContract(); telemetry.telemetryService.getIsOptedIn = jest.fn().mockReturnValue(false); - const component = shallow( - // @ts-ignore - {}} telemetry={telemetry} /> - ); + const component = shallow( {}} telemetry={telemetry} />); expect(component).toMatchSnapshot(); }); test('should render a Welcome screen with no telemetry disclaimer', () => { - // @ts-ignore - const component = shallow( - // @ts-ignore - {}} telemetry={null} /> - ); + const component = shallow( {}} />); expect(component).toMatchSnapshot(); }); test('fires opt-in seen when mounted', () => { - const telemetry = telemetryPluginMock.createSetupContract(); + const telemetry = telemetryPluginMock.createStartContract(); const mockSetOptedInNoticeSeen = jest.fn(); - // @ts-ignore telemetry.telemetryNotifications.setOptedInNoticeSeen = mockSetOptedInNoticeSeen; - shallow( - // @ts-ignore - {}} telemetry={telemetry} /> - ); + shallow( {}} telemetry={telemetry} />); expect(mockSetOptedInNoticeSeen).toHaveBeenCalled(); }); diff --git a/src/plugins/home/public/application/components/welcome.tsx b/src/plugins/home/public/application/components/welcome.tsx index d4dcaca317806e..f82bd024b80b83 100644 --- a/src/plugins/home/public/application/components/welcome.tsx +++ b/src/plugins/home/public/application/components/welcome.tsx @@ -38,7 +38,6 @@ import { METRIC_TYPE } from '@kbn/analytics'; import { FormattedMessage } from '@kbn/i18n/react'; import { getServices } from '../kibana_services'; import { TelemetryPluginStart } from '../../../../telemetry/public'; -import { PRIVACY_STATEMENT_URL } from '../../../../telemetry/common/constants'; import { SampleDataCard } from './sample_data'; interface Props { @@ -162,7 +161,11 @@ export class Welcome extends React.Component { id="home.dataManagementDisclaimerPrivacy" defaultMessage="To learn about how usage data helps us manage and improve our products and services, see our " /> - + { let savedObjectsClient: ReturnType; - const createObj = (id: number): SavedObject => ({ + const createObj = (id: number): SavedObjectsFindResult => ({ type: 'type', id: `id-${id}`, attributes: {}, + score: 1, references: [], }); diff --git a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts index 2c8997c9af21ab..e18a45d9bdf44c 100644 --- a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts +++ b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts @@ -77,6 +77,7 @@ describe('findRelationships', () => { type: 'parent-type', id: 'parent-id', attributes: {}, + score: 1, references: [], }, ], diff --git a/src/plugins/telemetry/public/mocks.ts b/src/plugins/telemetry/public/mocks.ts index 9ec4a3ae86cc73..dd7e5a4cc4ce30 100644 --- a/src/plugins/telemetry/public/mocks.ts +++ b/src/plugins/telemetry/public/mocks.ts @@ -25,7 +25,7 @@ import { httpServiceMock } from '../../../core/public/http/http_service.mock'; import { notificationServiceMock } from '../../../core/public/notifications/notifications_service.mock'; import { TelemetryService } from './services/telemetry_service'; import { TelemetryNotifications } from './services/telemetry_notifications/telemetry_notifications'; -import { TelemetryPluginStart, TelemetryPluginConfig } from './plugin'; +import { TelemetryPluginStart, TelemetryPluginSetup, TelemetryPluginConfig } from './plugin'; // The following is to be able to access private methods /* eslint-disable dot-notation */ @@ -77,20 +77,35 @@ export function mockTelemetryNotifications({ }); } -export type Setup = jest.Mocked; +export type Setup = jest.Mocked; +export type Start = jest.Mocked; export const telemetryPluginMock = { createSetupContract, + createStartContract, }; function createSetupContract(): Setup { const telemetryService = mockTelemetryService(); - const telemetryNotifications = mockTelemetryNotifications({ telemetryService }); const setupContract: Setup = { telemetryService, - telemetryNotifications, }; return setupContract; } + +function createStartContract(): Start { + const telemetryService = mockTelemetryService(); + const telemetryNotifications = mockTelemetryNotifications({ telemetryService }); + + const startContract: Start = { + telemetryService, + telemetryNotifications, + telemetryConstants: { + getPrivacyStatementUrl: jest.fn(), + }, + }; + + return startContract; +} diff --git a/src/plugins/telemetry/public/plugin.ts b/src/plugins/telemetry/public/plugin.ts index a363953978d79d..3846e7cb96a191 100644 --- a/src/plugins/telemetry/public/plugin.ts +++ b/src/plugins/telemetry/public/plugin.ts @@ -38,6 +38,7 @@ import { getTelemetrySendUsageFrom, } from '../common/telemetry_config'; import { getNotifyUserAboutOptInDefault } from '../common/telemetry_config/get_telemetry_notify_user_about_optin_default'; +import { PRIVACY_STATEMENT_URL } from '../common/constants'; export interface TelemetryPluginSetup { telemetryService: TelemetryService; @@ -46,6 +47,9 @@ export interface TelemetryPluginSetup { export interface TelemetryPluginStart { telemetryService: TelemetryService; telemetryNotifications: TelemetryNotifications; + telemetryConstants: { + getPrivacyStatementUrl: () => string; + }; } export interface TelemetryPluginConfig { @@ -115,6 +119,9 @@ export class TelemetryPlugin implements Plugin PRIVACY_STATEMENT_URL, + }, }; } diff --git a/test/accessibility/services/a11y/a11y.ts b/test/accessibility/services/a11y/a11y.ts index 525445c60818ad..35cef2b81d64ea 100644 --- a/test/accessibility/services/a11y/a11y.ts +++ b/test/accessibility/services/a11y/a11y.ts @@ -99,6 +99,9 @@ export function A11yProvider({ getService }: FtrProviderContext) { 'color-contrast': { enabled: false, }, + bypass: { + enabled: false, // disabled because it's too flaky + }, }, }; diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index 7a57d182bc8124..7cb5955e4a43d9 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -46,6 +46,7 @@ export default function ({ getService }) { attributes: { title: 'Count of requests', }, + score: 0, migrationVersion: resp.body.saved_objects[0].migrationVersion, references: [ { @@ -134,6 +135,7 @@ export default function ({ getService }) { .searchSourceJSON, }, }, + score: 0, references: [ { name: 'kibanaSavedObjectMeta.searchSourceJSON.index', diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index e15a9e989d21ff..4d9f1c1658139e 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -56,6 +56,7 @@ export default function ({ getService }: FtrProviderContext) { type: 'index-pattern', }, ], + score: 0, updated_at: '2017-09-21T18:51:23.794Z', meta: { editUrl: diff --git a/test/common/services/kibana_server/kibana_server.ts b/test/common/services/kibana_server/kibana_server.ts index 16039d6fee8335..4a251cca044d3c 100644 --- a/test/common/services/kibana_server/kibana_server.ts +++ b/test/common/services/kibana_server/kibana_server.ts @@ -27,9 +27,9 @@ export function KibanaServerProvider({ getService }: FtrProviderContext) { const config = getService('config'); const lifecycle = getService('lifecycle'); const url = Url.format(config.get('servers.kibana')); + const ssl = config.get('servers.kibana').ssl; const defaults = config.get('uiSettings.defaults'); - - const kbn = new KbnClient(log, [url], defaults); + const kbn = new KbnClient(log, { url, ssl }, defaults); if (defaults) { lifecycle.beforeTests.add(async () => { diff --git a/test/common/services/security/role.ts b/test/common/services/security/role.ts index dfc6ff9b164e50..caa5549a70f0c4 100644 --- a/test/common/services/security/role.ts +++ b/test/common/services/security/role.ts @@ -17,27 +17,20 @@ * under the License. */ -import axios, { AxiosInstance } from 'axios'; import util from 'util'; -import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient, ToolingLog } from '@kbn/dev-utils'; export class Role { - private log: ToolingLog; - private axios: AxiosInstance; - - constructor(url: string, log: ToolingLog) { - this.log = log; - this.axios = axios.create({ - headers: { 'kbn-xsrf': 'x-pack/ftr/services/security/role' }, - baseURL: url, - maxRedirects: 0, - validateStatus: () => true, // we do our own validation below and throw better error messages - }); - } + constructor(private log: ToolingLog, private kibanaServer: KbnClient) {} public async create(name: string, role: any) { this.log.debug(`creating role ${name}`); - const { data, status, statusText } = await this.axios.put(`/api/security/role/${name}`, role); + const { data, status, statusText } = await this.kibanaServer.request({ + path: `/api/security/role/${name}`, + method: 'PUT', + body: role, + retries: 0, + }); if (status !== 204) { throw new Error( `Expected status code of 204, received ${status} ${statusText}: ${util.inspect(data)}` @@ -47,7 +40,10 @@ export class Role { public async delete(name: string) { this.log.debug(`deleting role ${name}`); - const { data, status, statusText } = await this.axios.delete(`/api/security/role/${name}`); + const { data, status, statusText } = await this.kibanaServer.request({ + path: `/api/security/role/${name}`, + method: 'DELETE', + }); if (status !== 204 && status !== 404) { throw new Error( `Expected status code of 204 or 404, received ${status} ${statusText}: ${util.inspect( diff --git a/test/common/services/security/role_mappings.ts b/test/common/services/security/role_mappings.ts index cc2fa238254987..7951d4b5b47b27 100644 --- a/test/common/services/security/role_mappings.ts +++ b/test/common/services/security/role_mappings.ts @@ -17,30 +17,19 @@ * under the License. */ -import axios, { AxiosInstance } from 'axios'; import util from 'util'; -import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient, ToolingLog } from '@kbn/dev-utils'; export class RoleMappings { - private log: ToolingLog; - private axios: AxiosInstance; - - constructor(url: string, log: ToolingLog) { - this.log = log; - this.axios = axios.create({ - headers: { 'kbn-xsrf': 'x-pack/ftr/services/security/role_mappings' }, - baseURL: url, - maxRedirects: 0, - validateStatus: () => true, // we do our own validation below and throw better error messages - }); - } + constructor(private log: ToolingLog, private kbnClient: KbnClient) {} public async create(name: string, roleMapping: Record) { this.log.debug(`creating role mapping ${name}`); - const { data, status, statusText } = await this.axios.post( - `/internal/security/role_mapping/${name}`, - roleMapping - ); + const { data, status, statusText } = await this.kbnClient.request({ + path: `/internal/security/role_mapping/${name}`, + method: 'POST', + body: roleMapping, + }); if (status !== 200) { throw new Error( `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}` @@ -51,9 +40,10 @@ export class RoleMappings { public async delete(name: string) { this.log.debug(`deleting role mapping ${name}`); - const { data, status, statusText } = await this.axios.delete( - `/internal/security/role_mapping/${name}` - ); + const { data, status, statusText } = await this.kbnClient.request({ + path: `/internal/security/role_mapping/${name}`, + method: 'DELETE', + }); if (status !== 200 && status !== 404) { throw new Error( `Expected status code of 200 or 404, received ${status} ${statusText}: ${util.inspect( diff --git a/test/common/services/security/security.ts b/test/common/services/security/security.ts index 6ad0933a2a5a23..fae4c9198cab6d 100644 --- a/test/common/services/security/security.ts +++ b/test/common/services/security/security.ts @@ -17,8 +17,6 @@ * under the License. */ -import { format as formatUrl } from 'url'; - import { Role } from './role'; import { User } from './user'; import { RoleMappings } from './role_mappings'; @@ -28,14 +26,14 @@ import { createTestUserService } from './test_user'; export async function SecurityServiceProvider(context: FtrProviderContext) { const { getService } = context; const log = getService('log'); - const config = getService('config'); - const url = formatUrl(config.get('servers.kibana')); - const role = new Role(url, log); - const user = new User(url, log); + const kibanaServer = getService('kibanaServer'); + + const role = new Role(log, kibanaServer); + const user = new User(log, kibanaServer); const testUser = await createTestUserService(role, user, context); return new (class SecurityService { - roleMappings = new RoleMappings(url, log); + roleMappings = new RoleMappings(log, kibanaServer); testUser = testUser; role = role; user = user; diff --git a/test/common/services/security/user.ts b/test/common/services/security/user.ts index ae02127043234c..58c4d0f1cf34ea 100644 --- a/test/common/services/security/user.ts +++ b/test/common/services/security/user.ts @@ -17,33 +17,22 @@ * under the License. */ -import axios, { AxiosInstance } from 'axios'; import util from 'util'; -import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient, ToolingLog } from '@kbn/dev-utils'; export class User { - private log: ToolingLog; - private axios: AxiosInstance; - - constructor(url: string, log: ToolingLog) { - this.log = log; - this.axios = axios.create({ - headers: { 'kbn-xsrf': 'x-pack/ftr/services/security/user' }, - baseURL: url, - maxRedirects: 0, - validateStatus: () => true, // we do our own validation below and throw better error messages - }); - } + constructor(private log: ToolingLog, private kbnClient: KbnClient) {} public async create(username: string, user: any) { this.log.debug(`creating user ${username}`); - const { data, status, statusText } = await this.axios.post( - `/internal/security/users/${username}`, - { + const { data, status, statusText } = await this.kbnClient.request({ + path: `/internal/security/users/${username}`, + method: 'POST', + body: { username, ...user, - } - ); + }, + }); if (status !== 200) { throw new Error( `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}` @@ -54,9 +43,10 @@ export class User { public async delete(username: string) { this.log.debug(`deleting user ${username}`); - const { data, status, statusText } = await this.axios.delete( - `/internal/security/users/${username}` - ); + const { data, status, statusText } = await await this.kbnClient.request({ + path: `/internal/security/users/${username}`, + method: 'DELETE', + }); if (status !== 204) { throw new Error( `Expected status code of 204, received ${status} ${statusText}: ${util.inspect(data)}` diff --git a/test/functional/apps/dashboard/dashboard_snapshots.js b/test/functional/apps/dashboard/dashboard_snapshots.js index 787e839aa08a5c..20bc30c889d651 100644 --- a/test/functional/apps/dashboard/dashboard_snapshots.js +++ b/test/functional/apps/dashboard/dashboard_snapshots.js @@ -28,7 +28,8 @@ export default function ({ getService, getPageObjects, updateBaselines }) { const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardAddPanel = getService('dashboardAddPanel'); - describe('dashboard snapshots', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/52854 + describe.skip('dashboard snapshots', function describeIndexTests() { before(async function () { await esArchiver.load('dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index 3297f6e094f7c8..d6a4fc91481de2 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -529,5 +529,10 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { await driver.executeScript('document.body.scrollLeft = ' + scrollSize); return this.getScrollLeft(); } + + public async switchToFrame(idOrElement: number | WebElementWrapper) { + const _id = idOrElement instanceof WebElementWrapper ? idOrElement._webElement : idOrElement; + await driver.switchTo().frame(_id); + } })(); } diff --git a/test/functional/services/remote/remote.ts b/test/functional/services/remote/remote.ts index 5a3a775cae0c50..99643929c4682c 100644 --- a/test/functional/services/remote/remote.ts +++ b/test/functional/services/remote/remote.ts @@ -23,7 +23,7 @@ import { resolve } from 'path'; import { mergeMap } from 'rxjs/operators'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { initWebDriver } from './webdriver'; +import { initWebDriver, BrowserConfig } from './webdriver'; import { Browsers } from './browsers'; export async function RemoteProvider({ getService }: FtrProviderContext) { @@ -58,12 +58,12 @@ export async function RemoteProvider({ getService }: FtrProviderContext) { Fs.writeFileSync(path, JSON.stringify(JSON.parse(coverageJson), null, 2)); }; - const { driver, consoleLog$ } = await initWebDriver( - log, - browserType, - lifecycle, - config.get('browser.logPollingMs') - ); + const browserConfig: BrowserConfig = { + logPollingMs: config.get('browser.logPollingMs'), + acceptInsecureCerts: config.get('browser.acceptInsecureCerts'), + }; + + const { driver, consoleLog$ } = await initWebDriver(log, browserType, lifecycle, browserConfig); const isW3CEnabled = (driver as any).executor_.w3c; const caps = await driver.getCapabilities(); diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index 9fbbf28bbf42cb..27814060e70c1d 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -73,13 +73,18 @@ Executor.prototype.execute = preventParallelCalls( (command: { getName: () => string }) => NO_QUEUE_COMMANDS.includes(command.getName()) ); +export interface BrowserConfig { + logPollingMs: number; + acceptInsecureCerts: boolean; +} + let attemptCounter = 0; let edgePaths: { driverPath: string | undefined; browserPath: string | undefined }; async function attemptToCreateCommand( log: ToolingLog, browserType: Browsers, lifecycle: Lifecycle, - logPollingMs: number + config: BrowserConfig ) { const attemptId = ++attemptCounter; log.debug('[webdriver] Creating session'); @@ -114,6 +119,7 @@ async function attemptToCreateCommand( if (certValidation === '0') { chromeOptions.push('ignore-certificate-errors'); } + if (remoteDebug === '1') { // Visit chrome://inspect in chrome to remotely view/debug chromeOptions.push('headless', 'disable-gpu', 'remote-debugging-port=9222'); @@ -125,6 +131,7 @@ async function attemptToCreateCommand( }); chromeCapabilities.set('unexpectedAlertBehaviour', 'accept'); chromeCapabilities.set('goog:loggingPrefs', { browser: 'ALL' }); + chromeCapabilities.setAcceptInsecureCerts(config.acceptInsecureCerts); const session = await new Builder() .forBrowser(browserType) @@ -137,7 +144,7 @@ async function attemptToCreateCommand( consoleLog$: pollForLogEntry$( session, logging.Type.BROWSER, - logPollingMs, + config.logPollingMs, lifecycle.cleanup.after$ ).pipe( takeUntil(lifecycle.cleanup.after$), @@ -174,7 +181,7 @@ async function attemptToCreateCommand( consoleLog$: pollForLogEntry$( session, logging.Type.BROWSER, - logPollingMs, + config.logPollingMs, lifecycle.cleanup.after$ ).pipe( takeUntil(lifecycle.cleanup.after$), @@ -206,6 +213,7 @@ async function attemptToCreateCommand( 'browser.helperApps.neverAsk.saveToDisk', 'application/comma-separated-values, text/csv, text/plain' ); + firefoxOptions.setAcceptInsecureCerts(config.acceptInsecureCerts); if (headlessBrowser === '1') { // See: https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Headless_mode @@ -317,7 +325,7 @@ export async function initWebDriver( log: ToolingLog, browserType: Browsers, lifecycle: Lifecycle, - logPollingMs: number + config: BrowserConfig ) { const logger = getLogger('webdriver.http.Executor'); logger.setLevel(logging.Level.FINEST); @@ -348,7 +356,7 @@ export async function initWebDriver( while (true) { const command = await Promise.race([ delay(30 * SECOND), - attemptToCreateCommand(log, browserType, lifecycle, logPollingMs), + attemptToCreateCommand(log, browserType, lifecycle, config), ]); if (!command) { diff --git a/test/scripts/jenkins_xpack_firefox_smoke.sh b/test/scripts/jenkins_xpack_firefox_smoke.sh index fdaee76cafa9de..ae924a5e105527 100755 --- a/test/scripts/jenkins_xpack_firefox_smoke.sh +++ b/test/scripts/jenkins_xpack_firefox_smoke.sh @@ -7,4 +7,5 @@ checks-reporter-with-killswitch "X-Pack firefox smoke test" \ --debug --bail \ --kibana-install-dir "$KIBANA_INSTALL_DIR" \ --include-tag "includeFirefox" \ - --config test/functional/config.firefox.js; + --config test/functional/config.firefox.js \ + --config test/functional_embedded/config.firefox.ts; diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index ca9af5d2346cd2..4a5c8dff8a2301 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -98,7 +98,7 @@ def withGcsArtifactUpload(workerName, closure) { def uploadPrefix = "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/${workerName}" def ARTIFACT_PATTERNS = [ 'target/kibana-*', - 'target/kibana-siem/**/*.png', + 'target/kibana-security-solution/**/*.png', 'target/junit/**/*', 'test/**/screenshots/**/*.png', 'test/functional/failure_debug/html/*.html', diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 21b2df3ba12f84..278968cb472315 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -13,7 +13,6 @@ "xpack.crossClusterReplication": "plugins/cross_cluster_replication", "xpack.dashboardMode": "legacy/plugins/dashboard_mode", "xpack.data": "plugins/data_enhanced", - "xpack.drilldowns": "plugins/drilldowns", "xpack.embeddableEnhanced": "plugins/embeddable_enhanced", "xpack.endpoint": "plugins/endpoint", "xpack.features": "plugins/features", diff --git a/x-pack/legacy/plugins/beats_management/common/config_block_validation.ts b/x-pack/legacy/plugins/beats_management/common/config_block_validation.ts deleted file mode 100644 index f3d1b9164e9768..00000000000000 --- a/x-pack/legacy/plugins/beats_management/common/config_block_validation.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import * as t from 'io-ts'; -import { PathReporter } from 'io-ts/lib/PathReporter'; -import { isLeft } from 'fp-ts/lib/Either'; -import { configBlockSchemas } from './config_schemas'; -import { ConfigurationBlock, createConfigurationBlockInterface } from './domain_types'; - -export const validateConfigurationBlocks = (configurationBlocks: ConfigurationBlock[]) => { - const validationMap = { - isHosts: t.array(t.string), - isString: t.string, - isPeriod: t.string, - isPath: t.string, - isPaths: t.array(t.string), - isYaml: t.string, - }; - - for (const [index, block] of configurationBlocks.entries()) { - const blockSchema = configBlockSchemas.find((s) => s.id === block.type); - if (!blockSchema) { - throw new Error( - `Invalid config type of ${block.type} used in 'configuration_blocks' at index ${index}` - ); - } - - const interfaceConfig = blockSchema.configs.reduce((props, config) => { - if (config.options) { - props[config.id] = t.keyof( - Object.fromEntries(config.options.map((opt) => [opt.value, null])) as Record - ); - } else if (config.validation) { - props[config.id] = validationMap[config.validation]; - } - - return props; - }, {} as t.Props); - - const runtimeInterface = createConfigurationBlockInterface( - t.literal(blockSchema.id), - t.interface(interfaceConfig) - ); - - const validationResults = runtimeInterface.decode(block); - - if (isLeft(validationResults)) { - throw new Error( - `configuration_blocks validation error, configuration_blocks at index ${index} is invalid. ${ - PathReporter.report(validationResults)[0] - }` - ); - } - } -}; diff --git a/x-pack/legacy/plugins/beats_management/common/config_schemas_translations_map.ts b/x-pack/legacy/plugins/beats_management/common/config_schemas_translations_map.ts index 1aec3e80817088..7cae2a85dc4ca8 100644 --- a/x-pack/legacy/plugins/beats_management/common/config_schemas_translations_map.ts +++ b/x-pack/legacy/plugins/beats_management/common/config_schemas_translations_map.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { ConfigBlockSchema } from './domain_types'; -export const supportedConfigLabelsMap = new Map([ +const supportedConfigLabelsMap = new Map([ [ 'filebeatInputConfig.paths.ui.label', i18n.translate('xpack.beatsManagement.filebeatInputConfig.pathsLabel', { diff --git a/x-pack/legacy/plugins/beats_management/common/domain_types.ts b/x-pack/legacy/plugins/beats_management/common/domain_types.ts index b4a9ac8a074798..32e1d81451c652 100644 --- a/x-pack/legacy/plugins/beats_management/common/domain_types.ts +++ b/x-pack/legacy/plugins/beats_management/common/domain_types.ts @@ -7,8 +7,6 @@ import * as t from 'io-ts'; import { configBlockSchemas } from './config_schemas'; import { DateFromString } from './io_ts_types'; -export const OutputTypesArray = ['elasticsearch', 'logstash', 'kafka', 'redis']; - // Here we create the runtime check for a generic, unknown beat config type. // We can also pass in optional params to create spacific runtime checks that // can be used to validate blocs on the API and UI diff --git a/x-pack/legacy/plugins/beats_management/common/io_ts_types.ts b/x-pack/legacy/plugins/beats_management/common/io_ts_types.ts index d77ad922986995..7d71ea5ad82562 100644 --- a/x-pack/legacy/plugins/beats_management/common/io_ts_types.ts +++ b/x-pack/legacy/plugins/beats_management/common/io_ts_types.ts @@ -7,7 +7,7 @@ import * as t from 'io-ts'; import { isRight } from 'fp-ts/lib/Either'; -export class DateFromStringType extends t.Type { +class DateFromStringType extends t.Type { // eslint-disable-next-line public readonly _tag: 'DateFromISOStringType' = 'DateFromISOStringType'; constructor() { diff --git a/x-pack/legacy/plugins/beats_management/common/return_types.ts b/x-pack/legacy/plugins/beats_management/common/return_types.ts index a7125795a5c7d5..7e0e39e12e60aa 100644 --- a/x-pack/legacy/plugins/beats_management/common/return_types.ts +++ b/x-pack/legacy/plugins/beats_management/common/return_types.ts @@ -34,11 +34,6 @@ export interface ReturnTypeBulkCreate extends BaseReturnType { }>; } -// delete -export interface ReturnTypeDelete extends BaseReturnType { - action: 'deleted'; -} - export interface ReturnTypeBulkDelete extends BaseReturnType { results: Array<{ success: boolean; @@ -84,12 +79,6 @@ export interface ReturnTypeBulkGet extends BaseReturnType { items: T[]; } -// action -- e.g. validate config block. Like ES simulate endpoint -export interface ReturnTypeAction extends BaseReturnType { - result: { - [key: string]: any; - }; -} // e.g. // { // result: { diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/beats/memory_beats_adapter.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/beats/memory_beats_adapter.ts deleted file mode 100644 index afae87c4901588..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/beats/memory_beats_adapter.ts +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { intersection, omit } from 'lodash'; - -import { CMBeat } from '../../../../common/domain_types'; -import { FrameworkUser } from '../framework/adapter_types'; -import { BeatsTagAssignment, CMBeatsAdapter } from './adapter_types'; - -export class MemoryBeatsAdapter implements CMBeatsAdapter { - private beatsDB: CMBeat[]; - - constructor(beatsDB: CMBeat[]) { - this.beatsDB = beatsDB; - } - - public async get(user: FrameworkUser, id: string) { - return this.beatsDB.find((beat) => beat.id === id) || null; - } - - public async insert(user: FrameworkUser, beat: CMBeat) { - this.beatsDB.push(beat); - } - - public async update(user: FrameworkUser, beat: CMBeat) { - const beatIndex = this.beatsDB.findIndex((b) => b.id === beat.id); - - this.beatsDB[beatIndex] = { - ...this.beatsDB[beatIndex], - ...beat, - }; - } - - public async getWithIds(user: FrameworkUser, beatIds: string[]) { - return this.beatsDB.filter((beat) => beatIds.includes(beat.id)); - } - - public async getAllWithTags(user: FrameworkUser, tagIds: string[]): Promise { - return this.beatsDB.filter((beat) => intersection(tagIds, beat.tags || []).length !== 0); - } - - public async getBeatWithToken( - user: FrameworkUser, - enrollmentToken: string - ): Promise { - return this.beatsDB.find((beat) => enrollmentToken === beat.enrollment_token) || null; - } - - public async getAll(user: FrameworkUser) { - return this.beatsDB.map((beat: any) => omit(beat, ['access_token'])); - } - - public async removeTagsFromBeats( - user: FrameworkUser, - removals: BeatsTagAssignment[] - ): Promise { - const beatIds = removals.map((r) => r.beatId); - - const response = this.beatsDB - .filter((beat) => beatIds.includes(beat.id)) - .map((beat) => { - const tagData = removals.find((r) => r.beatId === beat.id); - if (tagData) { - if (beat.tags) { - beat.tags = beat.tags.filter((tag) => tag !== tagData.tag); - } - } - return beat; - }); - - return response.map((item: CMBeat, resultIdx: number) => ({ - idxInRequest: removals[resultIdx].idxInRequest, - result: 'updated', - status: 200, - })); - } - - public async assignTagsToBeats( - user: FrameworkUser, - assignments: BeatsTagAssignment[] - ): Promise { - const beatIds = assignments.map((r) => r.beatId); - - this.beatsDB - .filter((beat) => beatIds.includes(beat.id)) - .map((beat) => { - // get tags that need to be assigned to this beat - const tags = assignments - .filter((a) => a.beatId === beat.id) - .map((t: BeatsTagAssignment) => t.tag); - - if (tags.length > 0) { - if (!beat.tags) { - beat.tags = []; - } - const nonExistingTags = tags.filter((t: string) => beat.tags && !beat.tags.includes(t)); - - if (nonExistingTags.length > 0) { - beat.tags = beat.tags.concat(nonExistingTags); - } - } - return beat; - }); - - return assignments.map((item: BeatsTagAssignment, resultIdx: number) => ({ - idxInRequest: assignments[resultIdx].idxInRequest, - result: 'updated', - status: 200, - })); - } - - public setDB(beatsDB: CMBeat[]) { - this.beatsDB = beatsDB; - } -} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/configuration_blocks/memory_tags_adapter.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/configuration_blocks/memory_tags_adapter.ts deleted file mode 100644 index ea8a75c92fad27..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/configuration_blocks/memory_tags_adapter.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Chance from 'chance'; // eslint-disable-line -import { ConfigurationBlock } from '../../../../common/domain_types'; -import { FrameworkUser } from '../framework/adapter_types'; -import { ConfigurationBlockAdapter } from './adapter_types'; - -const chance = new Chance(); - -export class MemoryConfigurationBlockAdapter implements ConfigurationBlockAdapter { - private db: ConfigurationBlock[] = []; - - constructor(db: ConfigurationBlock[]) { - this.db = db.map((config) => { - if (config.id === undefined) { - config.id = chance.word(); - } - return config as ConfigurationBlock & { id: string }; - }); - } - - public async getByIds(user: FrameworkUser, ids: string[]) { - return this.db.filter((block) => ids.includes(block.id)); - } - public async delete(user: FrameworkUser, blockIds: string[]) { - this.db = this.db.filter((block) => !blockIds.includes(block.id)); - return blockIds.map((id) => ({ - id, - success: true, - })); - } - public async deleteForTags( - user: FrameworkUser, - tagIds: string[] - ): Promise<{ success: boolean; reason?: string }> { - this.db = this.db.filter((block) => !tagIds.includes(block.tag)); - return { - success: true, - }; - } - - public async getForTags(user: FrameworkUser, tagIds: string[], page?: number, size?: number) { - const results = this.db.filter((block) => tagIds.includes(block.id)); - return { - page: 0, - total: results.length, - blocks: results, - }; - } - - public async create(user: FrameworkUser, blocks: ConfigurationBlock[]) { - return blocks.map((block) => { - const existingIndex = this.db.findIndex((t) => t.id === block.id); - if (existingIndex !== -1) { - this.db[existingIndex] = block; - } else { - this.db.push(block); - } - return block.id; - }); - } - - public setDB(db: ConfigurationBlock[]) { - this.db = db.map((block) => { - if (block.id === undefined) { - block.id = chance.word(); - } - return block; - }); - } -} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/__tests__/kibana.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/__tests__/kibana.ts deleted file mode 100644 index 460fc412e94910..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/__tests__/kibana.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -// file.skip - -// @ts-ignore -import { createLegacyEsTestCluster } from '@kbn/test'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Root } from 'src/core/server/root'; -// @ts-ignore -import * as kbnTestServer from '../../../../../../../../../src/test_utils/kbn_server'; -import { DatabaseKbnESPlugin } from '../adapter_types'; -import { KibanaDatabaseAdapter } from '../kibana_database_adapter'; -import { contractTests } from './test_contract'; -const es = createLegacyEsTestCluster({}); - -let legacyServer: any; -let rootServer: Root; -contractTests('Kibana Database Adapter', { - before: async () => { - await es.start(); - - rootServer = kbnTestServer.createRootWithCorePlugins({ - server: { maxPayloadBytes: 100 }, - }); - - await rootServer.setup(); - legacyServer = kbnTestServer.getKbnServer(rootServer); - return await legacyServer.plugins.elasticsearch.waitUntilReady(); - }, - after: async () => { - await rootServer.shutdown(); - return await es.cleanup(); - }, - adapterSetup: () => { - return new KibanaDatabaseAdapter(legacyServer.plugins.elasticsearch as DatabaseKbnESPlugin); - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/__tests__/test_contract.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/__tests__/test_contract.ts deleted file mode 100644 index 369c2e10562118..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/__tests__/test_contract.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { DatabaseAdapter } from '../adapter_types'; - -interface ContractConfig { - before?(): Promise; - after?(): Promise; - adapterSetup(): DatabaseAdapter; -} - -export const contractTests = (testName: string, config: ContractConfig) => { - describe.skip(testName, () => { - let database: DatabaseAdapter; - beforeAll(async () => { - jest.setTimeout(100000); // 1 second - - if (config.before) { - await config.before(); - } - }); - afterAll(async () => config.after && (await config.after())); - beforeEach(async () => { - database = config.adapterSetup(); - }); - - it('Unauthorized users cant query', async () => { - const params = { - id: `beat:foo`, - ignore: [404], - index: '.management-beats', - }; - let ranWithoutError = false; - try { - await database.get({ kind: 'unauthenticated' }, params); - ranWithoutError = true; - } catch (e) { - expect(e).not.toEqual(null); - } - expect(ranWithoutError).toEqual(false); - }); - - it('Should query ES', async () => { - const params = { - id: `beat:foo`, - ignore: [404], - index: '.management-beats', - }; - const response = await database.get({ kind: 'internal' }, params); - - expect(response).not.toEqual(undefined); - // @ts-ignore - expect(response.found).toEqual(undefined); - }); - }); -}; diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/adapter_types.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/adapter_types.ts index 0a06c3dcc6412d..90519840af213b 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/adapter_types.ts +++ b/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/adapter_types.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { FrameworkRequest, FrameworkUser } from '../framework/adapter_types'; +import { FrameworkUser } from '../framework/adapter_types'; export interface DatabaseAdapter { get( @@ -39,15 +39,6 @@ export interface DatabaseAdapter { putTemplate(name: string, template: any): Promise; } -export interface DatabaseKbnESCluster { - callWithInternalUser(esMethod: string, options: {}): Promise; - callWithRequest(req: FrameworkRequest, esMethod: string, options: {}): Promise; -} - -export interface DatabaseKbnESPlugin { - getCluster(clusterName: string): DatabaseKbnESCluster; -} - export interface DatabaseSearchParams extends DatabaseGenericParams { analyzer?: string; analyzeWildcard?: boolean; diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/kibana_database_adapter.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/kibana_database_adapter.ts index 1ca3bcae8bfca6..baccbe416f3980 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/kibana_database_adapter.ts +++ b/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/kibana_database_adapter.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { ElasticsearchServiceStart, IClusterClient } from 'src/core/server'; import { FrameworkUser } from '../framework/adapter_types'; import { internalAuthData } from './../framework/adapter_types'; import { @@ -15,8 +16,6 @@ import { DatabaseGetDocumentResponse, DatabaseGetParams, DatabaseIndexDocumentParams, - DatabaseKbnESCluster, - DatabaseKbnESPlugin, DatabaseMGetParams, DatabaseMGetResponse, DatabaseSearchParams, @@ -24,75 +23,67 @@ import { } from './adapter_types'; export class KibanaDatabaseAdapter implements DatabaseAdapter { - private es: DatabaseKbnESCluster; + private es: IClusterClient; - constructor(kbnElasticSearch: DatabaseKbnESPlugin) { - this.es = kbnElasticSearch.getCluster('admin'); + constructor(elasticsearch: ElasticsearchServiceStart) { + this.es = elasticsearch.legacy.client; } public async get( user: FrameworkUser, params: DatabaseGetParams ): Promise> { - const result = await this.callWithUser(user, 'get', params); - return result; - // todo + return await this.callWithUser(user, 'get', params); } public async mget( user: FrameworkUser, params: DatabaseMGetParams ): Promise> { - const result = await this.callWithUser(user, 'mget', params); - return result; - // todo + return await this.callWithUser(user, 'mget', params); } public async bulk(user: FrameworkUser, params: DatabaseBulkIndexDocumentsParams): Promise { - const result = await this.callWithUser(user, 'bulk', params); - return result; + return await this.callWithUser(user, 'bulk', params); } public async create( user: FrameworkUser, params: DatabaseCreateDocumentParams ): Promise { - const result = await this.callWithUser(user, 'create', params); - return result; + return await this.callWithUser(user, 'create', params); } + public async index(user: FrameworkUser, params: DatabaseIndexDocumentParams): Promise { - const result = await this.callWithUser(user, 'index', params); - return result; + return await this.callWithUser(user, 'index', params); } + public async delete( user: FrameworkUser, params: DatabaseDeleteDocumentParams ): Promise { - const result = await this.callWithUser(user, 'delete', params); - return result; + return await this.callWithUser(user, 'delete', params); } public async deleteByQuery( user: FrameworkUser, params: DatabaseSearchParams ): Promise { - const result = await this.callWithUser(user, 'deleteByQuery', params); - return result; + return await this.callWithUser(user, 'deleteByQuery', params); } public async search( user: FrameworkUser, params: DatabaseSearchParams ): Promise> { - const result = await this.callWithUser(user, 'search', params); - return result; + return await this.callWithUser(user, 'search', params); } public async searchAll( user: FrameworkUser, params: DatabaseSearchParams ): Promise> { - const result = await this.callWithUser(user, 'search', { + return await this.callWithUser(user, 'search', { scroll: '1m', ...params, body: { @@ -100,29 +91,24 @@ export class KibanaDatabaseAdapter implements DatabaseAdapter { ...params.body, }, }); - return result; } public async putTemplate(name: string, template: any): Promise { - const result = await this.callWithUser({ kind: 'internal' }, 'indices.putTemplate', { + return await this.callWithUser({ kind: 'internal' }, 'indices.putTemplate', { name, body: template, }); - - return result; } private callWithUser(user: FrameworkUser, esMethod: string, options: any = {}): any { if (user.kind === 'authenticated') { - return this.es.callWithRequest( - { + return this.es + .asScoped({ headers: user[internalAuthData], - } as any, - esMethod, - options - ); + }) + .callAsCurrentUser(esMethod, options); } else if (user.kind === 'internal') { - return this.es.callWithInternalUser(esMethod, options); + return this.es.callAsInternalUser(esMethod, options); } else { throw new Error('Invalid user type'); } diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/events/adapter_types.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/events/adapter_types.ts deleted file mode 100644 index 4cb38bb3d057b6..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/events/adapter_types.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { BeatEvent } from '../../../../common/domain_types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { FrameworkUser } from '../../../../../../../plugins/beats_management/public/lib/adapters/framework/adapter_types'; - -export interface BeatEventsAdapter { - bulkInsert(user: FrameworkUser, beatId: string, events: BeatEvent[]): Promise; -} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/events/elasticsearch_beat_events_adapter.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/events/elasticsearch_beat_events_adapter.ts deleted file mode 100644 index b5056140c8b860..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/events/elasticsearch_beat_events_adapter.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BeatEvent } from '../../../../common/domain_types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { FrameworkUser } from '../../../../../../../plugins/beats_management/public/lib/adapters/framework/adapter_types'; -import { DatabaseAdapter } from '../database/adapter_types'; -import { BeatEventsAdapter } from './adapter_types'; - -export class ElasticsearchBeatEventsAdapter implements BeatEventsAdapter { - // @ts-ignore - constructor(private readonly database: DatabaseAdapter) {} - - // eslint-disable-next-line - public bulkInsert = async (user: FrameworkUser, beatId: string, events: BeatEvent[]) => { - // await this.database.putTemplate(INDEX_NAMES.EVENTS_TODAY, beatsIndexTemplate); - }; -} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/adapter_types.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/adapter_types.ts index 80599f38d982ab..e2703cb5786dd8 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/adapter_types.ts @@ -8,6 +8,7 @@ import { Lifecycle, ResponseToolkit } from 'hapi'; import * as t from 'io-ts'; +import { CoreSetup, CoreStart } from 'src/core/server'; import { SecurityPluginSetup } from '../../../../../../../plugins/security/server'; import { LicenseType } from '../../../../common/constants/security'; @@ -33,7 +34,6 @@ export interface BackendFrameworkAdapter { log(text: string): void; on(event: 'xpack.status.green' | 'elasticsearch.status.green', cb: () => void): void; getSetting(settingPath: string): any; - exposeStaticDir(urlPath: string, dir: string): void; registerRoute( route: FrameworkRouteOptions ): void; @@ -42,8 +42,12 @@ export interface BackendFrameworkAdapter { export interface KibanaLegacyServer { newPlatform: { setup: { + core: CoreSetup; plugins: { security: SecurityPluginSetup }; }; + start: { + core: CoreStart; + }; }; plugins: { xpack_main: { diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/hapi_framework_adapter.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/hapi_framework_adapter.ts deleted file mode 100644 index 90500e02835116..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/hapi_framework_adapter.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { LicenseType } from './../../../../common/constants/security'; -import { KibanaServerRequest } from './adapter_types'; -import { - BackendFrameworkAdapter, - FrameworkInfo, - FrameworkRequest, - FrameworkResponse, - FrameworkRouteOptions, - internalAuthData, - internalUser, -} from './adapter_types'; - -interface TestSettings { - enrollmentTokensTtlInSeconds: number; - encryptionKey: string; -} - -export class HapiBackendFrameworkAdapter implements BackendFrameworkAdapter { - public info: null | FrameworkInfo = null; - public readonly internalUser = internalUser; - - private settings: TestSettings; - private server: any; - - constructor( - settings: TestSettings = { - encryptionKey: 'something_who_cares', - enrollmentTokensTtlInSeconds: 10 * 60, // 10 minutes - }, - hapiServer?: any, - license: LicenseType = 'trial', - securityEnabled: boolean = true, - licenseActive: boolean = true - ) { - this.server = hapiServer; - this.settings = settings; - const now = new Date(); - - this.info = { - kibana: { - version: 'unknown', - }, - license: { - type: license, - expired: !licenseActive, - expiry_date_in_millis: new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime(), - }, - security: { - enabled: securityEnabled, - available: securityEnabled, - }, - watcher: { - enabled: true, - available: true, - }, - }; - } - public log(text: string) { - this.server.log(text); - } - public on(event: 'xpack.status.green', cb: () => void) { - cb(); - } - public getSetting(settingPath: string) { - switch (settingPath) { - case 'xpack.beats.enrollmentTokensTtlInSeconds': - return this.settings.enrollmentTokensTtlInSeconds; - case 'xpack.beats.encryptionKey': - return this.settings.encryptionKey; - } - } - - public exposeStaticDir(urlPath: string, dir: string): void { - if (!this.server) { - throw new Error('Must pass a hapi server into the adapter to use exposeStaticDir'); - } - this.server.route({ - handler: { - directory: { - path: dir, - }, - }, - method: 'GET', - path: urlPath, - }); - } - - public registerRoute< - RouteRequest extends FrameworkRequest, - RouteResponse extends FrameworkResponse - >(route: FrameworkRouteOptions) { - if (!this.server) { - throw new Error('Must pass a hapi server into the adapter to use registerRoute'); - } - const wrappedHandler = (licenseRequired: string[]) => (request: any, h: any) => { - return route.handler(this.wrapRequest(request), h); - }; - - this.server.route({ - handler: wrappedHandler(route.licenseRequired || []), - method: route.method, - path: route.path, - config: { - ...route.config, - auth: false, - }, - }); - } - - public async injectRequstForTesting({ method, url, headers, payload }: any) { - return await this.server.inject({ method, url, headers, payload }); - } - - private wrapRequest( - req: InternalRequest - ): FrameworkRequest { - const { params, payload, query, headers, info } = req; - - const isAuthenticated = headers.authorization != null; - - return { - user: isAuthenticated - ? { - kind: 'authenticated', - [internalAuthData]: headers, - username: 'elastic', - roles: ['superuser'], - full_name: null, - email: null, - enabled: true, - } - : { - kind: 'unauthenticated', - }, - headers, - info, - params, - payload, - query, - }; - } -} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/integration_tests/kibana.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/integration_tests/kibana.ts deleted file mode 100644 index 4f0ba01b860825..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/integration_tests/kibana.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -// file.skip - -import { camelCase } from 'lodash'; -// @ts-ignore -import * as kbnTestServer from '../../../../../../../../../src/test_utils/kbn_server'; -// @ts-ignore -import { TestKbnServerConfig } from '../../../../../../../test_utils/kbn_server_config'; -import { CONFIG_PREFIX } from '../../../../../common/constants/plugin'; -import { PLUGIN } from './../../../../../common/constants/plugin'; -import { KibanaBackendFrameworkAdapter } from './../kibana_framework_adapter'; -import { contractTests } from './test_contract'; - -let kbnServer: any; -let kbn: any; -let esServer: any; -contractTests('Kibana Framework Adapter', { - async before() { - const servers = kbnTestServer.createTestServers({ - adjustTimeout: (t: number) => jest.setTimeout(t), - settings: TestKbnServerConfig, - }); - esServer = await servers.startES(); - kbn = await servers.startKibana(); - kbnServer = kbn.kbnServer; - }, - async after() { - await kbn.stop(); - await esServer.stop(); - }, - adapterSetup: () => { - return new KibanaBackendFrameworkAdapter(camelCase(PLUGIN.ID), kbnServer.server, CONFIG_PREFIX); - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/integration_tests/test_contract.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/integration_tests/test_contract.ts deleted file mode 100644 index 8e21f8cf78ad7c..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/integration_tests/test_contract.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { BackendFrameworkAdapter } from '../adapter_types'; - -interface ContractConfig { - before(): Promise; - after(): Promise; - adapterSetup(): BackendFrameworkAdapter; -} - -export const contractTests = (testName: string, config: ContractConfig) => { - describe(testName, () => { - let frameworkAdapter: BackendFrameworkAdapter; - beforeAll(config.before); - afterAll(config.after); - beforeEach(async () => { - frameworkAdapter = config.adapterSetup(); - }); - - it('Should have tests here', () => { - expect(frameworkAdapter.info).toHaveProperty('server'); - - expect(frameworkAdapter).toHaveProperty('server'); - }); - }); -}; diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/kibana_framework_adapter.ts index 1bf9bbb22b3525..3b29e50e4465b9 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/kibana_framework_adapter.ts @@ -68,18 +68,6 @@ export class KibanaBackendFrameworkAdapter implements BackendFrameworkAdapter { this.server.log(text); } - public exposeStaticDir(urlPath: string, dir: string): void { - this.server.route({ - handler: { - directory: { - path: dir, - }, - }, - method: 'GET', - path: urlPath, - }); - } - public registerRoute< RouteRequest extends FrameworkRequest, RouteResponse extends FrameworkResponse diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/tags/memory_tags_adapter.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/tags/memory_tags_adapter.ts deleted file mode 100644 index 66a6c7ebebc2c5..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/tags/memory_tags_adapter.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BeatTag } from '../../../../common/domain_types'; -import { FrameworkUser } from '../framework/adapter_types'; -import { CMTagsAdapter } from './adapter_types'; - -export class MemoryTagsAdapter implements CMTagsAdapter { - private tagsDB: BeatTag[] = []; - - constructor(tagsDB: BeatTag[]) { - this.tagsDB = tagsDB; - } - - public async getAll(user: FrameworkUser) { - return this.tagsDB; - } - public async delete(user: FrameworkUser, tagIds: string[]) { - this.tagsDB = this.tagsDB.filter((tag) => !tagIds.includes(tag.id)); - - return true; - } - public async getTagsWithIds(user: FrameworkUser, tagIds: string[]) { - return this.tagsDB.filter((tag) => tagIds.includes(tag.id)); - } - - public async upsertTag(user: FrameworkUser, tag: BeatTag) { - const existingTagIndex = this.tagsDB.findIndex((t) => t.id === tag.id); - if (existingTagIndex !== -1) { - this.tagsDB[existingTagIndex] = tag; - } else { - this.tagsDB.push(tag); - } - return tag.id; - } - - public async getWithoutConfigTypes( - user: FrameworkUser, - blockTypes: string[] - ): Promise { - return this.tagsDB.filter((tag) => tag.hasConfigurationBlocksTypes.includes(blockTypes[0])); - } - - public setDB(tagsDB: BeatTag[]) { - this.tagsDB = tagsDB; - } -} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/tokens/memory_tokens_adapter.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/tokens/memory_tokens_adapter.ts deleted file mode 100644 index 431263c808b45d..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/tokens/memory_tokens_adapter.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { FrameworkAuthenticatedUser, FrameworkUser } from '../framework/adapter_types'; -import { CMTokensAdapter, TokenEnrollmentData } from './adapter_types'; - -export class MemoryTokensAdapter implements CMTokensAdapter { - private tokenDB: TokenEnrollmentData[]; - - constructor(tokenDB: TokenEnrollmentData[]) { - this.tokenDB = tokenDB; - } - - public async deleteEnrollmentToken(user: FrameworkUser, enrollmentToken: string) { - const index = this.tokenDB.findIndex((token) => token.token === enrollmentToken); - - if (index > -1) { - this.tokenDB.splice(index, 1); - } - } - - public async getEnrollmentToken( - user: FrameworkUser, - tokenString: string - ): Promise { - return new Promise((resolve) => { - return resolve(this.tokenDB.find((token) => token.token === tokenString)); - }); - } - - public async insertTokens(user: FrameworkAuthenticatedUser, tokens: TokenEnrollmentData[]) { - tokens.forEach((token) => { - const existingIndex = this.tokenDB.findIndex((t) => t.token === token.token); - if (existingIndex !== -1) { - this.tokenDB[existingIndex] = token; - } else { - this.tokenDB.push(token); - } - }); - return tokens; - } - - public setDB(tokenDB: TokenEnrollmentData[]) { - this.tokenDB = tokenDB; - } -} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/beat_events.ts b/x-pack/legacy/plugins/beats_management/server/lib/beat_events.ts index f4cb3cb424f6f0..54782783f94ca1 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/beat_events.ts +++ b/x-pack/legacy/plugins/beats_management/server/lib/beat_events.ts @@ -6,13 +6,11 @@ import { PathReporter } from 'io-ts/lib/PathReporter'; import { isLeft } from 'fp-ts/lib/Either'; import { BeatEvent, RuntimeBeatEvent } from '../../common/domain_types'; -import { BeatEventsAdapter } from './adapters/events/adapter_types'; import { FrameworkUser } from './adapters/framework/adapter_types'; import { CMBeatsDomain } from './beats'; export class BeatEventsLib { - // @ts-ignore - constructor(private readonly adapter: BeatEventsAdapter, private readonly beats: CMBeatsDomain) {} + constructor(private readonly beats: CMBeatsDomain) {} public log = async ( user: FrameworkUser, diff --git a/x-pack/legacy/plugins/beats_management/server/lib/beats.ts b/x-pack/legacy/plugins/beats_management/server/lib/beats.ts index 3b9c4d35d8331a..6b7053f40550b7 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/beats.ts +++ b/x-pack/legacy/plugins/beats_management/server/lib/beats.ts @@ -7,7 +7,6 @@ import { uniq } from 'lodash'; import moment from 'moment'; import { CMBeat } from '../../common/domain_types'; -import { findNonExistentItems } from '../utils/find_non_existent_items'; import { BeatsRemovalReturn, BeatsTagAssignment, @@ -249,3 +248,12 @@ function addToResultsToResponse(key: string, response: any, assignmentResults: a }); return response; } + +export function findNonExistentItems(items: Array<{ id: string }>, requestedItems: string[]) { + return requestedItems.reduce((nonExistentItems: string[], requestedItem: string, idx: number) => { + if (items.findIndex((item) => item && item.id === requestedItem) === -1) { + nonExistentItems.push(requestedItems[idx]); + } + return nonExistentItems; + }, []); +} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/compose/kibana.ts b/x-pack/legacy/plugins/beats_management/server/lib/compose/kibana.ts index 2bda2fe85d62ff..b6a645ded81647 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/compose/kibana.ts +++ b/x-pack/legacy/plugins/beats_management/server/lib/compose/kibana.ts @@ -9,9 +9,7 @@ import { PLUGIN } from '../../../common/constants'; import { CONFIG_PREFIX } from '../../../common/constants/plugin'; import { ElasticsearchBeatsAdapter } from '../adapters/beats/elasticsearch_beats_adapter'; import { ElasticsearchConfigurationBlockAdapter } from '../adapters/configuration_blocks/elasticsearch_configuration_block_adapter'; -import { DatabaseKbnESPlugin } from '../adapters/database/adapter_types'; import { KibanaDatabaseAdapter } from '../adapters/database/kibana_database_adapter'; -import { ElasticsearchBeatEventsAdapter } from '../adapters/events/elasticsearch_beat_events_adapter'; import { KibanaLegacyServer } from '../adapters/framework/adapter_types'; import { KibanaBackendFrameworkAdapter } from '../adapters/framework/kibana_framework_adapter'; import { ElasticsearchTagsAdapter } from '../adapters/tags/elasticsearch_tags_adapter'; @@ -28,7 +26,7 @@ export function compose(server: KibanaLegacyServer): CMServerLibs { const framework = new BackendFrameworkLib( new KibanaBackendFrameworkAdapter(camelCase(PLUGIN.ID), server, CONFIG_PREFIX) ); - const database = new KibanaDatabaseAdapter(server.plugins.elasticsearch as DatabaseKbnESPlugin); + const database = new KibanaDatabaseAdapter(server.newPlatform.start.core.elasticsearch); const beatsAdapter = new ElasticsearchBeatsAdapter(database); const configAdapter = new ElasticsearchConfigurationBlockAdapter(database); @@ -46,7 +44,7 @@ export function compose(server: KibanaLegacyServer): CMServerLibs { tokens, framework, }); - const beatEvents = new BeatEventsLib(new ElasticsearchBeatEventsAdapter(database), beats); + const beatEvents = new BeatEventsLib(beats); const libs: CMServerLibs = { beatEvents, diff --git a/x-pack/legacy/plugins/beats_management/server/lib/compose/testing.ts b/x-pack/legacy/plugins/beats_management/server/lib/compose/testing.ts deleted file mode 100644 index b5fe6195fc7c70..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/compose/testing.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { MemoryBeatsAdapter } from '../adapters/beats/memory_beats_adapter'; -import { MemoryConfigurationBlockAdapter } from '../adapters/configuration_blocks/memory_tags_adapter'; -import { HapiBackendFrameworkAdapter } from '../adapters/framework/hapi_framework_adapter'; -import { MemoryTagsAdapter } from '../adapters/tags/memory_tags_adapter'; -import { MemoryTokensAdapter } from '../adapters/tokens/memory_tokens_adapter'; -import { BeatEventsLib } from '../beat_events'; -import { CMBeatsDomain } from '../beats'; -import { ConfigurationBlocksLib } from '../configuration_blocks'; -import { BackendFrameworkLib } from '../framework'; -import { CMTagsDomain } from '../tags'; -import { CMTokensDomain } from '../tokens'; -import { CMServerLibs } from '../types'; - -export function compose(server: any): CMServerLibs { - const framework = new BackendFrameworkLib(new HapiBackendFrameworkAdapter(undefined, server)); - - const beatsAdapter = new MemoryBeatsAdapter(server.beatsDB || []); - const configAdapter = new MemoryConfigurationBlockAdapter(server.configsDB || []); - const tags = new CMTagsDomain( - new MemoryTagsAdapter(server.tagsDB || []), - configAdapter, - beatsAdapter - ); - const configurationBlocks = new ConfigurationBlocksLib(configAdapter, tags); - const tokens = new CMTokensDomain(new MemoryTokensAdapter(server.tokensDB || []), { - framework, - }); - const beats = new CMBeatsDomain(beatsAdapter, { - tags, - tokens, - framework, - }); - const beatEvents = new BeatEventsLib({} as any, beats); - - const libs: CMServerLibs = { - beatEvents, - framework, - beats, - tags, - tokens, - configurationBlocks, - }; - - return libs; -} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/framework.ts b/x-pack/legacy/plugins/beats_management/server/lib/framework.ts index 1a6f84a6979c6f..96a06929073e5c 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/framework.ts +++ b/x-pack/legacy/plugins/beats_management/server/lib/framework.ts @@ -16,7 +16,6 @@ import { export class BackendFrameworkLib { public log = this.adapter.log; public on = this.adapter.on.bind(this.adapter); - public exposeStaticDir = this.adapter.exposeStaticDir; public internalUser = this.adapter.internalUser; constructor(private readonly adapter: BackendFrameworkAdapter) { this.validateConfig(); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/__tests__/beats_assignments.test.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/__tests__/beats_assignments.test.ts deleted file mode 100644 index 156304443431d1..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/__tests__/beats_assignments.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CMServerLibs } from '../../lib/types'; -import { HapiBackendFrameworkAdapter } from './../../lib/adapters/framework/hapi_framework_adapter'; -import { testHarnes } from './test_harnes'; - -describe('assign_tags_to_beats', () => { - let serverLibs: CMServerLibs; - - beforeAll(async () => { - jest.setTimeout(100000); // 1 second - - serverLibs = await testHarnes.getServerLibs(); - }); - beforeEach(async () => await testHarnes.loadData()); - - it('should add a single tag to a single beat', async () => { - const { result, statusCode } = await ((serverLibs.framework as any) - .adapter as HapiBackendFrameworkAdapter).injectRequstForTesting({ - method: 'POST', - url: '/api/beats/agents_tags/assignments', - headers: { - 'kbn-xsrf': 'xxx', - authorization: 'loggedin', - }, - payload: { - assignments: [{ beatId: 'bar', tag: 'production' }], - }, - }); - - expect(statusCode).toEqual(200); - expect(result.results).toEqual([{ success: true, result: { message: 'updated' } }]); - }); - - it('should not re-add an existing tag to a beat', async () => { - const { result, statusCode } = await ((serverLibs.framework as any) - .adapter as HapiBackendFrameworkAdapter).injectRequstForTesting({ - method: 'POST', - url: '/api/beats/agents_tags/assignments', - headers: { - 'kbn-xsrf': 'xxx', - authorization: 'loggedin', - }, - payload: { - assignments: [{ beatId: 'foo', tag: 'production' }], - }, - }); - - expect(statusCode).toEqual(200); - - expect(result.results).toEqual([{ success: true, result: { message: 'updated' } }]); - - const beat = await serverLibs.beats.getById( - { - kind: 'internal', - }, - 'foo' - ); - expect(beat!.tags).toEqual(['production', 'qa']); // as - }); - - it('should add a single tag to a multiple beats', async () => { - const { result, statusCode } = await ((serverLibs.framework as any) - .adapter as HapiBackendFrameworkAdapter).injectRequstForTesting({ - method: 'POST', - url: '/api/beats/agents_tags/assignments', - headers: { - 'kbn-xsrf': 'xxx', - authorization: 'loggedin', - }, - payload: { - assignments: [ - { beatId: 'foo', tag: 'development' }, - { beatId: 'bar', tag: 'development' }, - ], - }, - }); - - expect(statusCode).toEqual(200); - - expect(result.results).toEqual([ - { success: true, result: { message: 'updated' } }, - { success: true, result: { message: 'updated' } }, - ]); - - let beat; - - beat = await serverLibs.beats.getById( - { - kind: 'internal', - }, - 'foo' - ); - expect(beat!.tags).toEqual(['production', 'qa', 'development']); // as beat 'foo' already had 'production' and 'qa' tags attached to it - - // Beat bar - beat = await serverLibs.beats.getById( - { - kind: 'internal', - }, - 'bar' - ); - - expect(beat!.tags).toEqual(['development']); - }); - - it('should add multiple tags to a single beat', async () => { - const { result, statusCode } = await ((serverLibs.framework as any) - .adapter as HapiBackendFrameworkAdapter).injectRequstForTesting({ - method: 'POST', - url: '/api/beats/agents_tags/assignments', - headers: { - 'kbn-xsrf': 'xxx', - authorization: 'loggedin', - }, - payload: { - assignments: [ - { beatId: 'bar', tag: 'development' }, - { beatId: 'bar', tag: 'production' }, - ], - }, - }); - - expect(statusCode).toEqual(200); - - expect(result.results).toEqual([ - { success: true, result: { message: 'updated' } }, - { success: true, result: { message: 'updated' } }, - ]); - - const beat = await serverLibs.beats.getById( - { - kind: 'internal', - }, - 'bar' - ); - - expect(beat!.tags).toEqual(['development', 'production']); - }); - - // it('should add multiple tags to a multiple beats', async () => { - // const { body: apiResponse } = await supertest - // .post('/api/beats/agents_tags/assignments') - // .set('kbn-xsrf', 'xxx') - // .send({ - // assignments: [{ beatId: 'foo', tag: 'development' }, { beatId: 'bar', tag: 'production' }], - // }) - // .expect(200); - - // expect(apiResponse.assignments).to.eql([ - // { status: 200, result: 'updated' }, - // { status: 200, result: 'updated' }, - // ]); - - // let esResponse; - // let beat; - - // // Beat foo - // esResponse = await es.get({ - // index: ES_INDEX_NAME, - // type: ES_TYPE_NAME, - // id: `beat:foo`, - // }); - - // beat = esResponse._source.beat; - // expect(beat.tags).to.eql(['production', 'qa', 'development']); // as beat 'foo' already had 'production' and 'qa' tags attached to it - - // // Beat bar - // esResponse = await es.get({ - // index: ES_INDEX_NAME, - // type: ES_TYPE_NAME, - // id: `beat:bar`, - // }); - - // beat = esResponse._source.beat; - // expect(beat.tags).to.eql(['production']); - // }); - - // it('should return errors for non-existent beats', async () => { - // const nonExistentBeatId = chance.word(); - - // const { body: apiResponse } = await supertest - // .post('/api/beats/agents_tags/assignments') - // .set('kbn-xsrf', 'xxx') - // .send({ - // assignments: [{ beatId: nonExistentBeatId, tag: 'production' }], - // }) - // .expect(200); - - // expect(apiResponse.assignments).to.eql([ - // { status: 404, result: `Beat ${nonExistentBeatId} not found` }, - // ]); - // }); - - // it('should return errors for non-existent tags', async () => { - // const nonExistentTag = chance.word(); - - // const { body: apiResponse } = await supertest - // .post('/api/beats/agents_tags/assignments') - // .set('kbn-xsrf', 'xxx') - // .send({ - // assignments: [{ beatId: 'bar', tag: nonExistentTag }], - // }) - // .expect(200); - - // expect(apiResponse.assignments).to.eql([ - // { status: 404, result: `Tag ${nonExistentTag} not found` }, - // ]); - - // const esResponse = await es.get({ - // index: ES_INDEX_NAME, - // type: ES_TYPE_NAME, - // id: `beat:bar`, - // }); - - // const beat = esResponse._source.beat; - // expect(beat).to.not.have.property('tags'); - // }); - - // it('should return errors for non-existent beats and tags', async () => { - // const nonExistentBeatId = chance.word(); - // const nonExistentTag = chance.word(); - - // const { body: apiResponse } = await supertest - // .post('/api/beats/agents_tags/assignments') - // .set('kbn-xsrf', 'xxx') - // .send({ - // assignments: [{ beatID: nonExistentBeatId, tag: nonExistentTag }], - // }) - // .expect(200); - - // expect(apiResponse.assignments).to.eql([ - // { status: 404, result: `Beat ${nonExistentBeatId} and tag ${nonExistentTag} not found` }, - // ]); - - // const esResponse = await es.get({ - // index: ES_INDEX_NAME, - // type: ES_TYPE_NAME, - // id: `beat:bar`, - // }); - - // const beat = esResponse._source.beat; - // expect(beat).to.not.have.property('tags'); - // }); -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/__tests__/data.json b/x-pack/legacy/plugins/beats_management/server/rest_api/__tests__/data.json deleted file mode 100644 index 4ee5a4a7e2d55e..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/__tests__/data.json +++ /dev/null @@ -1,178 +0,0 @@ -{ - "value": { - "index": ".management-beats", - "type": "_doc", - "id": "beat:qux", - "source": { - "type": "beat", - "beat": { - "type": "filebeat", - "active": true, - "host_ip": "1.2.3.4", - "host_name": "foo.bar.com", - "id": "qux", - "name": "qux_filebeat", - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjcmVhdGVkIjoiMjAxOC0wNi0zMFQwMzo0MjoxNS4yMzBaIiwiaWF0IjoxNTMwMzMwMTM1fQ.SSsX2Byyo1B1bGxV8C3G4QldhE5iH87EY_1r21-bwbI" - } - } - } -} - -{ - "value": { - "index": ".management-beats", - "type": "_doc", - "id": "beat:baz", - "source": { - "type": "beat", - "beat": { - "type": "metricbeat", - "active": true, - "host_ip": "22.33.11.44", - "host_name": "baz.bar.com", - "id": "baz", - "name": "baz_metricbeat", - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjcmVhdGVkIjoiMjAxOC0wNi0zMFQwMzo0MjoxNS4yMzBaIiwiaWF0IjoxNTMwMzMwMTM1fQ.SSsX2Byyo1B1bGxV8C3G4QldhE5iH87EY_1r21-bwbI" - } - } - } -} - -{ - "value": { - "index": ".management-beats", - "type": "_doc", - "id": "beat:foo", - "source": { - "type": "beat", - "beat": { - "type": "metricbeat", - "active": true, - "host_ip": "1.2.3.4", - "host_name": "foo.bar.com", - "id": "foo", - "name": "foo_metricbeat", - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjcmVhdGVkIjoiMjAxOC0wNi0zMFQwMzo0MjoxNS4yMzBaIiwiaWF0IjoxNTMwMzMwMTM1fQ.SSsX2Byyo1B1bGxV8C3G4QldhE5iH87EY_1r21-bwbI", - "verified_on": "2018-05-15T16:25:38.924Z", - "tags": [ - "production", - "qa" - ] - } - } - } -} - -{ - "value": { - "index": ".management-beats", - "type": "_doc", - "id": "beat:bar", - "source": { - "type": "beat", - "beat": { - "type": "filebeat", - "active": true, - "host_ip": "11.22.33.44", - "host_name": "foo.com", - "id": "bar", - "name": "bar_filebeat", - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjcmVhdGVkIjoiMjAxOC0wNi0zMFQwMzo0MjoxNS4yMzBaIiwiaWF0IjoxNTMwMzMwMTM1fQ.SSsX2Byyo1B1bGxV8C3G4QldhE5iH87EY_1r21-bwbI" - } - } - } -} - -{ - "value": { - "index": ".management-beats", - "type": "_doc", - "id": "tag:production", - "source": { - "type": "tag", - "tag": { - "color": "blue" - } - } - } -} - -{ - "value": { - "index": ".management-beats", - "type": "_doc", - "id": "tag:development", - "source": { - "type": "tag", - "tag": { - "color": "red" - } - } - } -} - -{ - "value": { - "index": ".management-beats", - "type": "_doc", - "id": "tag:qa", - "source": { - "type": "tag", - "tag": { - "color": "green" - } - } - } -} - -{ - "value": { - "index": ".management-beats", - "type": "_doc", - "id": "configuration_block:SDfsdfIBdsfsf50zbta", - "source": { - "type": "configuration_block", - "configuration_block": { - "type": "output", - "description": "some description", - "tag": "production", - "last_updated": "2018-05-15T16:25:38.924Z", - "config": "{ \"username\": \"some-username\", \"hosts\": [\"localhost:11211\"] }" - } - } - } -} - -{ - "value": { - "index": ".management-beats", - "type": "_doc", - "id": "configuration_block:W0tpsmIBdsfsf50zbta", - "source": { - "type": "configuration_block", - "configuration_block": { - "type": "metricbeat.modules", - "tag": "production", - "last_updated": "2018-05-15T16:25:38.924Z", - "config": "{ \"module\": \"memcached\", \"hosts\": [\"localhost:11211\"] }" - } - } - } -} - -{ - "value": { - "index": ".management-beats", - "type": "_doc", - "id": "configuration_block:W0tpsmIBdwcYyG50zbta", - "source": { - "type": "configuration_block", - "configuration_block": { - "type": "metricbeat.modules", - "tag": "qa", - "last_updated": "2018-05-15T16:25:38.924Z", - "config": "{\"module\": \"memcached\", \"node.namespace\": \"node\", \"hosts\": [\"localhost:4949\"] }" - } - } - } -} \ No newline at end of file diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/__tests__/test_harnes.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/__tests__/test_harnes.ts deleted file mode 100644 index 590ce0bd7b287e..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/__tests__/test_harnes.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { badRequest } from 'boom'; -import { readFile } from 'fs'; -// @ts-ignore -import Hapi from 'hapi'; -import { resolve } from 'path'; -import { promisify } from 'util'; -import { BeatTag, CMBeat } from '../../../common/domain_types'; -import { TokenEnrollmentData } from '../../lib/adapters/tokens/adapter_types'; -import { compose } from '../../lib/compose/testing'; -import { CMServerLibs } from '../../lib/types'; -import { initManagementServer } from './../../management_server'; - -const readFileAsync = promisify(readFile); -let serverLibs: CMServerLibs; - -export const testHarnes = { - description: 'API Development Tests', - loadData: async () => { - if (!serverLibs) { - throw new Error('Server libs not composed yet...'); - } - const contents = await readFileAsync(resolve(__dirname, './data.json'), 'utf8'); - const database = contents.split(/\n\n/); - - // @ts-ignore the private access - serverLibs.beats.adapter.setDB( - database.reduce((inserts: CMBeat[], source) => { - const type = 'beat'; - const data = JSON.parse(source); - - if (data.value.source.type === type) { - inserts.push({ - id: data.value.id.substring(data.value.id.indexOf(':') + 1), - ...data.value.source[type], - }); - } - return inserts; - }, []) - ); - - // @ts-ignore the private access - serverLibs.tags.adapter.setDB( - database.reduce((inserts: BeatTag[], source) => { - const type = 'tag'; - const data = JSON.parse(source); - - if (data.value.source.type === type) { - inserts.push({ - id: data.value.id.substring(data.value.id.indexOf(':') + 1), - ...data.value.source[type], - }); - } - return inserts; - }, []) - ); - - // @ts-ignore the private access - serverLibs.tokens.adapter.setDB( - database.reduce((inserts: TokenEnrollmentData[], source) => { - const type = 'token'; - const data = JSON.parse(source); - - if (data.value.source.type === type) { - inserts.push({ - id: data.value.id.substring(data.value.id.indexOf(':') + 1), - ...data.value.source[type], - }); - } - return inserts; - }, []) - ); - }, - getServerLibs: async () => { - if (!serverLibs) { - const server = new Hapi.Server({ port: 111111 }); - const versionHeader = 'kbn-version'; - const xsrfHeader = 'kbn-xsrf'; - - server.ext('onPostAuth', (req: any, h: any) => { - const isSafeMethod = req.method === 'get' || req.method === 'head'; - const hasVersionHeader = versionHeader in req.headers; - const hasXsrfHeader = xsrfHeader in req.headers; - - if (!isSafeMethod && !hasVersionHeader && !hasXsrfHeader) { - throw badRequest(`Request must contain a ${xsrfHeader} header.`); - } - - return h.continue; - }); - - serverLibs = compose(server); - initManagementServer(serverLibs); - } - return serverLibs; - }, -}; diff --git a/x-pack/legacy/plugins/beats_management/server/utils/README.md b/x-pack/legacy/plugins/beats_management/server/utils/README.md deleted file mode 100644 index 8a6a27aa29867c..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/utils/README.md +++ /dev/null @@ -1 +0,0 @@ -Utils should be data processing functions and other tools.... all in all utils is basicly everything that is not an adaptor, or presenter and yet too much to put in a lib. \ No newline at end of file diff --git a/x-pack/legacy/plugins/beats_management/server/utils/find_non_existent_items.ts b/x-pack/legacy/plugins/beats_management/server/utils/find_non_existent_items.ts deleted file mode 100644 index 0e9b4f0b6fa5e0..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/utils/find_non_existent_items.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -interface RandomItem { - id: string; - [key: string]: any; -} - -export function findNonExistentItems(items: RandomItem[], requestedItems: any) { - return requestedItems.reduce((nonExistentItems: string[], requestedItem: string, idx: number) => { - if (items.findIndex((item: RandomItem) => item && item.id === requestedItem) === -1) { - nonExistentItems.push(requestedItems[idx]); - } - return nonExistentItems; - }, []); -} diff --git a/x-pack/legacy/plugins/beats_management/server/utils/helper_types.ts b/x-pack/legacy/plugins/beats_management/server/utils/helper_types.ts deleted file mode 100644 index 96f7b7bc79b626..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/utils/helper_types.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export type InterfaceExcept = Pick>; - -export function arrayFromEnum(e: any): T[] { - return Object.keys(e) - .filter((key) => isNaN(+key)) - .map((name) => e[name]) as T[]; -} diff --git a/x-pack/legacy/plugins/beats_management/server/utils/index_templates/beats_template.json b/x-pack/legacy/plugins/beats_management/server/utils/index_templates/beats_template.json deleted file mode 100644 index ba3a0aba6c2567..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/utils/index_templates/beats_template.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "index_patterns": [".management-beats"], - "version": 66000, - "settings": { - "index": { - "number_of_shards": 1, - "auto_expand_replicas": "0-1", - "codec": "best_compression" - } - }, - "mappings": { - "_doc": { - "dynamic": "strict", - "properties": { - "type": { - "type": "keyword" - }, - "configuration_block": { - "properties": { - "id": { - "type": "keyword" - }, - "type": { - "type": "keyword" - }, - "tag": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "config": { - "type": "keyword" - }, - "last_updated": { - "type": "date" - } - } - }, - "enrollment_token": { - "properties": { - "token": { - "type": "keyword" - }, - "expires_on": { - "type": "date" - } - } - }, - "tag": { - "properties": { - "id": { - "type": "keyword" - }, - "name": { - "type": "keyword" - }, - "color": { - "type": "keyword" - }, - "hasConfigurationBlocksTypes": { - "type": "keyword" - } - } - }, - "beat": { - "properties": { - "id": { - "type": "keyword" - }, - "config_status": { - "type": "keyword" - }, - "active": { - "type": "boolean" - }, - "last_checkin": { - "type": "date" - }, - "enrollment_token": { - "type": "keyword" - }, - "access_token": { - "type": "keyword" - }, - "verified_on": { - "type": "date" - }, - "type": { - "type": "keyword" - }, - "version": { - "type": "keyword" - }, - "host_ip": { - "type": "ip" - }, - "host_name": { - "type": "keyword" - }, - "ephemeral_id": { - "type": "keyword" - }, - "tags": { - "type": "keyword" - }, - "metadata": { - "dynamic": "true", - "type": "object" - }, - "name": { - "type": "keyword" - } - } - } - } - } - } -} diff --git a/x-pack/legacy/plugins/beats_management/server/utils/wrap_request.ts b/x-pack/legacy/plugins/beats_management/server/utils/wrap_request.ts deleted file mode 100644 index 57cf70a99a296c..00000000000000 --- a/x-pack/legacy/plugins/beats_management/server/utils/wrap_request.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - FrameworkRequest, - internalAuthData, - KibanaServerRequest, -} from '../lib/adapters/framework/adapter_types'; - -export function wrapRequest( - req: InternalRequest -): FrameworkRequest { - const { params, payload, query, headers, info } = req; - - const isAuthenticated = headers.authorization != null; - - return { - // @ts-ignore -- partial applucation, adapter adds other user data - user: isAuthenticated - ? { - kind: 'authenticated', - [internalAuthData]: headers, - } - : { - kind: 'unauthenticated', - }, - headers, - info, - params, - payload, - query, - }; -} diff --git a/x-pack/legacy/plugins/beats_management/types/eui.d.ts b/x-pack/legacy/plugins/beats_management/types/eui.d.ts deleted file mode 100644 index 636d0a2f7b51e1..00000000000000 --- a/x-pack/legacy/plugins/beats_management/types/eui.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/** - * /!\ These type definitions are temporary until the upstream @elastic/eui - * package includes them. - */ - -import * as eui from '@elastic/eui'; -import { Moment } from 'moment'; -import { ChangeEventHandler, MouseEventHandler, ReactType, Ref, FC } from 'react'; - -declare module '@elastic/eui' {} diff --git a/x-pack/legacy/plugins/beats_management/wallaby.js b/x-pack/legacy/plugins/beats_management/wallaby.js deleted file mode 100644 index 823f63b15bcb35..00000000000000 --- a/x-pack/legacy/plugins/beats_management/wallaby.js +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -const path = require('path'); -process.env.NODE_PATH = path.resolve(__dirname, '..', '..', '..', 'node_modules'); - -module.exports = function (wallaby) { - return { - debug: true, - files: [ - './tsconfig.json', - //'plugins/beats/public/**/*.+(js|jsx|ts|tsx|json|snap|css|less|sass|scss|jpg|jpeg|gif|png|svg)', - 'server/**/*.+(js|jsx|ts|tsx|json|snap|css|less|sass|scss|jpg|jpeg|gif|png|svg)', - 'common/**/*.+(js|jsx|ts|tsx|json|snap|css|less|sass|scss|jpg|jpeg|gif|png|svg)', - 'public/**/*.+(js|jsx|ts|tsx|json|snap|css|less|sass|scss|jpg|jpeg|gif|png|svg)', - '!**/*.test.ts', - ], - - tests: ['**/*.test.ts', '**/*.test.tsx'], - env: { - type: 'node', - runner: 'node', - }, - testFramework: { - type: 'jest', - //path: jestPath, - }, - compilers: { - '**/*.ts?(x)': wallaby.compilers.typeScript({ - typescript: require('typescript'), // eslint-disable-line - }), - '**/*.js': wallaby.compilers.babel({ - babelrc: false, - presets: [require.resolve('@kbn/babel-preset/node_preset')], - }), - }, - - setup: (wallaby) => { - const path = require('path'); - - const kibanaDirectory = path.resolve(wallaby.localProjectDir, '..', '..', '..'); - wallaby.testFramework.configure({ - rootDir: wallaby.localProjectDir, - moduleNameMapper: { - '^ui/(.*)': `${kibanaDirectory}/src/legacy/ui/public/$1`, - // eslint-disable-next-line - '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': `${kibanaDirectory}/src/dev/jest/mocks/file_mock.js`, - '\\.(css|less|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/style_mock.js`, - }, - testURL: 'http://localhost', - setupFiles: [`${kibanaDirectory}/x-pack/dev-tools/jest/setup/enzyme.js`], - snapshotSerializers: [`${kibanaDirectory}/node_modules/enzyme-to-json/serializer`], - transform: { - '^.+\\.js$': `${kibanaDirectory}/src/dev/jest/babel_transform.js`, - //"^.+\\.tsx?$": `${kibanaDirectory}/src/dev/jest/ts_transform.js`, - }, - }); - }, - }; -}; diff --git a/x-pack/package.json b/x-pack/package.json index e24d75cc0d9680..b40a1e43642516 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -61,6 +61,7 @@ "@types/d3-shape": "^1.3.1", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^2.1.1", + "@types/dragselect": "^1.13.1", "@types/elasticsearch": "^5.0.33", "@types/fancy-log": "^1.3.1", "@types/file-saver": "^2.0.0", diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 2865bbbe1d944a..69fab828e63de4 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -386,6 +386,7 @@ describe('getAll()', () => { foo: 'bar', }, }, + score: 1, references: [], }, ], diff --git a/x-pack/plugins/alerts/server/alerts_client.test.ts b/x-pack/plugins/alerts/server/alerts_client.test.ts index 9685f58b8fb31c..f494f1358980d1 100644 --- a/x-pack/plugins/alerts/server/alerts_client.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client.test.ts @@ -1667,6 +1667,7 @@ describe('find()', () => { }, ], }, + score: 1, references: [ { name: 'action_0', diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index 796f2992236f9f..d71d5f2cb480de 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -5,9 +5,12 @@ */ import { EuiTitle } from '@elastic/eui'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; -import { scaleUtc } from 'd3-scale'; import d3 from 'd3'; +import { scaleUtc } from 'd3-scale'; +import mean from 'lodash.mean'; import React from 'react'; import { asRelativeDateTimeRange } from '../../../../utils/formatters'; import { getTimezoneOffsetInMs } from '../../../shared/charts/CustomPlot/getTimezoneOffsetInMs'; @@ -17,7 +20,7 @@ import { EmptyMessage } from '../../../shared/EmptyMessage'; interface IBucket { key: number; - count: number; + count: number | undefined; } // TODO: cleanup duplication of this in distribution/get_distribution.ts (ErrorDistributionAPIResponse) and transactions/distribution/index.ts (TransactionDistributionAPIResponse) @@ -30,7 +33,7 @@ interface IDistribution { interface FormattedBucket { x0: number; x: number; - y: number; + y: number | undefined; } export function getFormattedBuckets( @@ -64,7 +67,7 @@ export function ErrorDistribution({ distribution, title }: Props) { distribution.bucketSize ); - if (!buckets || distribution.noHits) { + if (!buckets) { return ( bucket.y)) || 0; const xMin = d3.min(buckets, (d) => d.x0); const xMax = d3.max(buckets, (d) => d.x); const tickFormat = scaleUtc().domain([xMin, xMax]).tickFormat(); @@ -84,6 +88,7 @@ export function ErrorDistribution({ distribution, title }: Props) { {title} bucket.x} xType="time-utc" @@ -105,6 +110,17 @@ export function ErrorDistribution({ distribution, title }: Props) { values: { occCount: value }, }) } + legends={[ + { + color: theme.euiColorVis1, + // 0a abbreviates large whole numbers with metric prefixes like: 1000 = 1k, 32000 = 32k, 1000000 = 1m + legendValue: numeral(averageValue).format('0a'), + title: i18n.translate('xpack.apm.errorGroupDetails.avgLabel', { + defaultMessage: 'Avg.', + }), + legendClickDisabled: true, + }, + ]} />

); diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index d8885ec11c5111..225e5ef2f6ca21 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -26,6 +26,9 @@ import { ErrorDistribution } from './Distribution'; import { useLocation } from '../../../hooks/useLocation'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { useTrackPageview } from '../../../../../observability/public'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; +import { ErrorRateChart } from '../../shared/charts/ErrorRateChart'; +import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; const Titles = styled.div` margin-bottom: ${px(units.plus)}; @@ -61,49 +64,43 @@ export function ErrorGroupDetails() { const { urlParams, uiFilters } = useUrlParams(); const { serviceName, start, end, errorGroupId } = urlParams; - const { data: errorGroupData } = useFetcher( - (callApmApi) => { - if (serviceName && start && end && errorGroupId) { - return callApmApi({ - pathname: '/api/apm/services/{serviceName}/errors/{groupId}', - params: { - path: { - serviceName, - groupId: errorGroupId, - }, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters), - }, + const { data: errorGroupData } = useFetcher(() => { + if (serviceName && start && end && errorGroupId) { + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/errors/{groupId}', + params: { + path: { + serviceName, + groupId: errorGroupId, }, - }); - } - }, - [serviceName, start, end, errorGroupId, uiFilters] - ); - - const { data: errorDistributionData } = useFetcher( - (callApmApi) => { - if (serviceName && start && end && errorGroupId) { - return callApmApi({ - pathname: '/api/apm/services/{serviceName}/errors/distribution', - params: { - path: { - serviceName, - }, - query: { - start, - end, - groupId: errorGroupId, - uiFilters: JSON.stringify(uiFilters), - }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters), }, - }); - } - }, - [serviceName, start, end, errorGroupId, uiFilters] - ); + }, + }); + } + }, [serviceName, start, end, errorGroupId, uiFilters]); + + const { data: errorDistributionData } = useFetcher(() => { + if (serviceName && start && end && errorGroupId) { + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/errors/distribution', + params: { + path: { + serviceName, + }, + query: { + start, + end, + groupId: errorGroupId, + uiFilters: JSON.stringify(uiFilters), + }, + }, + }); + } + }, [serviceName, start, end, errorGroupId, uiFilters]); useTrackPageview({ app: 'apm', path: 'error_group_details' }); useTrackPageview({ app: 'apm', path: 'error_group_details', delay: 15000 }); @@ -185,16 +182,24 @@ export function ErrorGroupDetails() { )} - - + + + + + + + + + + {showDetails && ( diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx index ff031c5a86d116..73474208e26c02 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx @@ -13,64 +13,61 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; -import { ErrorGroupList } from './List'; -import { useUrlParams } from '../../../hooks/useUrlParams'; import { useTrackPageview } from '../../../../../observability/public'; import { PROJECTION } from '../../../../common/projections/typings'; +import { useFetcher } from '../../../hooks/useFetcher'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; +import { ErrorRateChart } from '../../shared/charts/ErrorRateChart'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; +import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; +import { ErrorGroupList } from './List'; +import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; const ErrorGroupOverview: React.FC = () => { const { urlParams, uiFilters } = useUrlParams(); const { serviceName, start, end, sortField, sortDirection } = urlParams; - const { data: errorDistributionData } = useFetcher( - (callApmApi) => { - if (serviceName && start && end) { - return callApmApi({ - pathname: '/api/apm/services/{serviceName}/errors/distribution', - params: { - path: { - serviceName, - }, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters), - }, + const { data: errorDistributionData } = useFetcher(() => { + if (serviceName && start && end) { + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/errors/distribution', + params: { + path: { + serviceName, }, - }); - } - }, - [serviceName, start, end, uiFilters] - ); + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters), + }, + }, + }); + } + }, [serviceName, start, end, uiFilters]); - const { data: errorGroupListData } = useFetcher( - (callApmApi) => { - const normalizedSortDirection = sortDirection === 'asc' ? 'asc' : 'desc'; + const { data: errorGroupListData } = useFetcher(() => { + const normalizedSortDirection = sortDirection === 'asc' ? 'asc' : 'desc'; - if (serviceName && start && end) { - return callApmApi({ - pathname: '/api/apm/services/{serviceName}/errors', - params: { - path: { - serviceName, - }, - query: { - start, - end, - sortField, - sortDirection: normalizedSortDirection, - uiFilters: JSON.stringify(uiFilters), - }, + if (serviceName && start && end) { + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/errors', + params: { + path: { + serviceName, }, - }); - } - }, - [serviceName, start, end, sortField, sortDirection, uiFilters] - ); + query: { + start, + end, + sortField, + sortDirection: normalizedSortDirection, + uiFilters: JSON.stringify(uiFilters), + }, + }, + }); + } + }, [serviceName, start, end, sortField, sortDirection, uiFilters]); useTrackPageview({ app: 'apm', @@ -102,20 +99,27 @@ const ErrorGroupOverview: React.FC = () => { - - - - - - + + + + + + + + + + + + + diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx index 0dbde5ea86a187..2d52ad88d20dca 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx @@ -4,8 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiButtonEmpty, +} from '@elastic/eui'; import React from 'react'; +import { i18n } from '@kbn/i18n'; import { ApmHeader } from '../../shared/ApmHeader'; import { ServiceDetailTabs } from './ServiceDetailTabs'; import { ServiceIntegrations } from './ServiceIntegrations'; @@ -33,6 +39,12 @@ export function ServiceDetails({ tab }: Props) { const isAlertingAvailable = isAlertingPluginEnabled && (canReadAlerts || canSaveAlerts); + const { core } = useApmPluginContext(); + + const ADD_DATA_LABEL = i18n.translate('xpack.apm.addDataButtonLabel', { + defaultMessage: 'Add data', + }); + return (
@@ -53,6 +65,16 @@ export function ServiceDetails({ tab }: Props) { /> )} + + + {ADD_DATA_LABEL} + + diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx index 2199349952d8dc..8775cebc0af559 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx @@ -56,18 +56,64 @@ function doZoom(cy: cytoscape.Core | undefined, increment: number) { } } +function useDebugDownloadUrl(cy?: cytoscape.Core) { + const [downloadUrl, setDownloadUrl] = useState(undefined); + const debug = sessionStorage.getItem('apm_debug') === 'true'; + + // Handle elements changes to update the download URL + useEffect(() => { + const elementsHandler: cytoscape.EventHandler = (event) => { + // @ts-ignore The `true` argument to `cy.json` is to flatten the elements + // (instead of having them broken into nodes/edges.) DefinitelyTyped has + // this wrong. + const elementsJson = event.cy.json(true)?.elements.map((element) => ({ + data: element.data, + })); + setDownloadUrl( + elementsJson.length > 0 && debug + ? `data:application/json;charset=utf-8,${encodeURIComponent( + JSON.stringify({ elements: elementsJson }, null, ' ') + )}` + : undefined + ); + }; + + if (cy) { + cy.on('add remove', elementsHandler); + } + + return () => { + if (cy) { + cy.off('add remove', undefined, elementsHandler); + } + }; + }, [cy, debug]); + + return downloadUrl; +} + export function Controls() { const cy = useContext(CytoscapeContext); const { urlParams } = useUrlParams(); const currentSearch = urlParams.kuery ?? ''; const [zoom, setZoom] = useState((cy && cy.zoom()) || 1); + const downloadUrl = useDebugDownloadUrl(cy); + // Handle zoom events useEffect(() => { + const zoomHandler: cytoscape.EventHandler = (event) => { + setZoom(event.cy.zoom()); + }; + if (cy) { - cy.on('zoom', (event) => { - setZoom(event.cy.zoom()); - }); + cy.on('zoom', zoomHandler); } + + return () => { + if (cy) { + cy.off('zoom', undefined, zoomHandler); + } + }; }, [cy]); function center() { @@ -102,6 +148,9 @@ export function Controls() { const centerLabel = i18n.translate('xpack.apm.serviceMap.center', { defaultMessage: 'Center', }); + const downloadLabel = i18n.translate('xpack.apm.serviceMap.download', { + defaultMessage: 'Download', + }); const viewFullMapLabel = i18n.translate('xpack.apm.serviceMap.viewFullMap', { defaultMessage: 'View full service map', }); @@ -165,6 +214,22 @@ export function Controls() { )} + {downloadUrl && ( + + + diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx index bbf3921b383fd3..e2609f893dfa0b 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx @@ -223,7 +223,7 @@ export function SettingsPage({ }} > - + {i18n.translate('xpack.apm.unsavedChanges', { defaultMessage: '{unsavedChangesCount, plural, =0{0 unsaved changes} one {1 unsaved change} other {# unsaved changes}} ', diff --git a/x-pack/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx index e85605e42981cb..a5bcec1501ad3f 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx @@ -12,10 +12,14 @@ import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; const SETUP_INSTRUCTIONS_LABEL = i18n.translate( 'xpack.apm.setupInstructionsButtonLabel', { - defaultMessage: 'Setup Instructions', + defaultMessage: 'Setup instructions', } ); +const ADD_DATA_LABEL = i18n.translate('xpack.apm.addDataButtonLabel', { + defaultMessage: 'Add data', +}); + // renders a filled button or a link as a kibana link to setup instructions export function SetupInstructionsLink({ buttonFill = false, @@ -30,8 +34,8 @@ export function SetupInstructionsLink({ {SETUP_INSTRUCTIONS_LABEL} ) : ( - - {SETUP_INSTRUCTIONS_LABEL} + + {ADD_DATA_LABEL} )} diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js index bf6cf083e00ec1..87ab81e738eb8b 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js @@ -105,7 +105,9 @@ export default function Legends({ return ( clickLegend(i)} + onClick={ + serie.legendClickDisabled ? undefined : () => clickLegend(i) + } disabled={seriesEnabledState[i]} text={text} color={serie.color} diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js index e1ffec3a8d97f5..7e74961e57ea1c 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js @@ -144,7 +144,7 @@ export class InnerCustomPlot extends PureComponent { const hasValidCoordinates = flatten(series.map((s) => s.data)).some((p) => isValidCoordinateValue(p.y) ); - const noHits = !hasValidCoordinates; + const noHits = this.props.noHits || !hasValidCoordinates; const plotValues = this.getPlotValues({ visibleSeries, @@ -234,6 +234,7 @@ InnerCustomPlot.propTypes = { firstSeen: PropTypes.number, }) ), + noHits: PropTypes.bool, }; InnerCustomPlot.defaultProps = { @@ -241,6 +242,8 @@ InnerCustomPlot.defaultProps = { tickFormatX: undefined, tickFormatY: (y) => y, truncateLegends: false, + xAxisTickSizeOuter: 0, + noHits: false, }; export default makeWidthFlexible(InnerCustomPlot); diff --git a/x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx new file mode 100644 index 00000000000000..7aafa9e1fdcec9 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiTitle } from '@elastic/eui'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { i18n } from '@kbn/i18n'; +import mean from 'lodash.mean'; +import React, { useCallback } from 'react'; +import { useChartsSync } from '../../../../hooks/useChartsSync'; +import { useFetcher } from '../../../../hooks/useFetcher'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { callApmApi } from '../../../../services/rest/createCallApmApi'; +import { unit } from '../../../../style/variables'; +import { asPercent } from '../../../../utils/formatters'; +// @ts-ignore +import CustomPlot from '../CustomPlot'; + +const tickFormatY = (y?: number) => { + return asPercent(y || 0, 1); +}; + +export const ErrorRateChart = () => { + const { urlParams, uiFilters } = useUrlParams(); + const syncedChartsProps = useChartsSync(); + + const { serviceName, start, end, errorGroupId } = urlParams; + const { data: errorRateData } = useFetcher(() => { + if (serviceName && start && end) { + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/errors/rate', + params: { + path: { + serviceName, + }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters), + groupId: errorGroupId, + }, + }, + }); + } + }, [serviceName, start, end, uiFilters, errorGroupId]); + + const combinedOnHover = useCallback( + (hoverX: number) => { + return syncedChartsProps.onHover(hoverX); + }, + [syncedChartsProps] + ); + + const errorRates = errorRateData?.errorRates || []; + + return ( + <> + + + {i18n.translate('xpack.apm.errorRateChart.title', { + defaultMessage: 'Error Rate', + })} + + + rate.y))), + legendClickDisabled: true, + title: i18n.translate('xpack.apm.errorRateChart.avgLabel', { + defaultMessage: 'Avg.', + }), + type: 'linemark', + hideTooltipValue: true, + }, + { + data: errorRates, + type: 'line', + color: theme.euiColorVis7, + hideLegend: true, + title: i18n.translate('xpack.apm.errorRateChart.rateLabel', { + defaultMessage: 'Rate', + }), + }, + ]} + onHover={combinedOnHover} + tickFormatY={tickFormatY} + formatTooltipValue={({ y }: { y?: number }) => + Number.isFinite(y) ? tickFormatY(y) : 'N/A' + } + height={unit * 10} + /> + + ); +}; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap index 1f935af7c89999..a31b9735628ab4 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap +++ b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap @@ -114,7 +114,7 @@ exports[`Histogram Initially should have default markup 1`] = ` x1={0} x2={0} y1={-0} - y2={10} + y2={0} /> 0 ms @@ -149,7 +149,7 @@ exports[`Histogram Initially should have default markup 1`] = ` x1={0} x2={0} y1={-0} - y2={10} + y2={0} /> 500 ms @@ -184,7 +184,7 @@ exports[`Histogram Initially should have default markup 1`] = ` x1={0} x2={0} y1={-0} - y2={10} + y2={0} /> 1,000 ms @@ -219,7 +219,7 @@ exports[`Histogram Initially should have default markup 1`] = ` x1={0} x2={0} y1={-0} - y2={10} + y2={0} /> 1,500 ms @@ -254,7 +254,7 @@ exports[`Histogram Initially should have default markup 1`] = ` x1={0} x2={0} y1={-0} - y2={10} + y2={0} /> 2,000 ms @@ -289,7 +289,7 @@ exports[`Histogram Initially should have default markup 1`] = ` x1={0} x2={0} y1={-0} - y2={10} + y2={0} /> 2,500 ms @@ -324,7 +324,7 @@ exports[`Histogram Initially should have default markup 1`] = ` x1={0} x2={0} y1={-0} - y2={10} + y2={0} /> 3,000 ms diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js index 4eca1a37c51bc2..002ff19d0d1df2 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js +++ b/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js @@ -26,6 +26,10 @@ import Tooltip from '../Tooltip'; import theme from '@elastic/eui/dist/eui_theme_light.json'; import { tint } from 'polished'; import { getTimeTicksTZ, getDomainTZ } from '../helper/timezone'; +import Legends from '../CustomPlot/Legends'; +import StatusText from '../CustomPlot/StatusText'; +import { i18n } from '@kbn/i18n'; +import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; const XY_HEIGHT = unit * 10; const XY_MARGIN = { @@ -99,6 +103,7 @@ export class HistogramInner extends PureComponent { tooltipHeader, verticalLineHover, width: XY_WIDTH, + legends, } = this.props; const { hoveredBucket } = this.state; if (isEmpty(buckets) || XY_WIDTH === 0) { @@ -139,102 +144,140 @@ export class HistogramInner extends PureComponent { const showVerticalLineHover = verticalLineHover(hoveredBucket); const showBackgroundHover = backgroundHover(hoveredBucket); + const hasValidCoordinates = buckets.some((bucket) => + isValidCoordinateValue(bucket.y) + ); + const noHits = this.props.noHits || !hasValidCoordinates; + + const xyPlotProps = { + dontCheckIfEmpty: true, + xType: this.props.xType, + width: XY_WIDTH, + height: XY_HEIGHT, + margin: XY_MARGIN, + xDomain: xDomain, + yDomain: yDomain, + }; + + const xAxisProps = { + style: { strokeWidth: '1px' }, + marginRight: 10, + tickSize: 0, + tickTotal: X_TICK_TOTAL, + tickFormat: formatX, + tickValues: xTickValues, + }; + + const emptyStateChart = ( + + + + + ); + return (
- - - - - - {showBackgroundHover && ( - - )} - - {shouldShowTooltip && ( - - )} - - {selectedBucket && ( - - )} - - - - {showVerticalLineHover && ( - - )} - - { - return { - ...bucket, - xCenter: (bucket.x0 + bucket.x) / 2, - }; - })} - onClick={this.onClick} - onHover={this.onHover} - onBlur={this.onBlur} - x={(d) => x(d.xCenter)} - y={() => 1} - /> - + {noHits ? ( + <>{emptyStateChart} + ) : ( + <> + + + + + + {showBackgroundHover && ( + + )} + + {shouldShowTooltip && ( + + )} + + {selectedBucket && ( + + )} + + + + {showVerticalLineHover && hoveredBucket?.x && ( + + )} + + { + return { + ...bucket, + xCenter: (bucket.x0 + bucket.x) / 2, + }; + })} + onClick={this.onClick} + onHover={this.onHover} + onBlur={this.onBlur} + x={(d) => x(d.xCenter)} + y={() => 1} + /> + + + {legends && ( + {}} + truncateLegends={false} + noHits={noHits} + /> + )} + + )}
); @@ -255,6 +298,8 @@ HistogramInner.propTypes = { verticalLineHover: PropTypes.func, width: PropTypes.number.isRequired, xType: PropTypes.string, + legends: PropTypes.array, + noHits: PropTypes.bool, }; HistogramInner.defaultProps = { @@ -265,6 +310,7 @@ HistogramInner.defaultProps = { tooltipHeader: () => null, verticalLineHover: () => null, xType: 'linear', + noHits: false, }; export default makeWidthFlexible(HistogramInner); diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts index 60a7be9391eea3..80f722bae08686 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -70,3 +70,6 @@ export const APM_FEATURE = { }, }, }; + +export const APM_SERVICE_MAPS_FEATURE_NAME = 'APM service maps'; +export const APM_SERVICE_MAPS_LICENSE_TYPE = 'platinum'; diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_rate.ts b/x-pack/plugins/apm/server/lib/errors/get_error_rate.ts new file mode 100644 index 00000000000000..d558e3942a42be --- /dev/null +++ b/x-pack/plugins/apm/server/lib/errors/get_error_rate.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + ERROR_GROUP_ID, + PROCESSOR_EVENT, + SERVICE_NAME, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { getMetricsDateHistogramParams } from '../helpers/metrics'; +import { rangeFilter } from '../helpers/range_filter'; +import { + Setup, + SetupTimeRange, + SetupUIFilters, +} from '../helpers/setup_request'; + +export async function getErrorRate({ + serviceName, + groupId, + setup, +}: { + serviceName: string; + groupId?: string; + setup: Setup & SetupTimeRange & SetupUIFilters; +}) { + const { start, end, uiFiltersES, client, indices } = setup; + + const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, + { range: rangeFilter(start, end) }, + ...uiFiltersES, + ]; + + const aggs = { + response_times: { + date_histogram: getMetricsDateHistogramParams(start, end), + }, + }; + + const getTransactionBucketAggregation = async () => { + const resp = await client.search({ + index: indices['apm_oss.transactionIndices'], + body: { + size: 0, + query: { + bool: { + filter: [ + ...filter, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + ], + }, + }, + aggs, + }, + }); + return { + totalHits: resp.hits.total.value, + responseTimeBuckets: resp.aggregations?.response_times.buckets, + }; + }; + const getErrorBucketAggregation = async () => { + const groupIdFilter = groupId + ? [{ term: { [ERROR_GROUP_ID]: groupId } }] + : []; + const resp = await client.search({ + index: indices['apm_oss.errorIndices'], + body: { + size: 0, + query: { + bool: { + filter: [ + ...filter, + ...groupIdFilter, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } }, + ], + }, + }, + aggs, + }, + }); + return resp.aggregations?.response_times.buckets; + }; + + const [transactions, errorResponseTimeBuckets] = await Promise.all([ + getTransactionBucketAggregation(), + getErrorBucketAggregation(), + ]); + + const transactionCountByTimestamp: Record = {}; + if (transactions?.responseTimeBuckets) { + transactions.responseTimeBuckets.forEach((bucket) => { + transactionCountByTimestamp[bucket.key] = bucket.doc_count; + }); + } + + const errorRates = errorResponseTimeBuckets?.map((bucket) => { + const { key, doc_count: errorCount } = bucket; + const relativeRate = errorCount / transactionCountByTimestamp[key]; + return { x: key, y: relativeRate }; + }); + + return { + noHits: transactions?.totalHits === 0, + errorRates, + }; +} diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index f0a05dfc0df30c..eb781ee0783075 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -14,7 +14,7 @@ import { import { Observable, combineLatest } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { ObservabilityPluginSetup } from '../../observability/server'; -import { SecurityPluginSetup } from '../../security/public'; +import { SecurityPluginSetup } from '../../security/server'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { TaskManagerSetupContract } from '../../task_manager/server'; import { AlertingPlugin } from '../../alerts/server'; @@ -28,11 +28,19 @@ import { APMConfig, mergeConfigs, APMXPackConfig } from '.'; import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; -import { LicensingPluginSetup } from '../../licensing/public'; +import { + LicensingPluginSetup, + LicensingPluginStart, +} from '../../licensing/server'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; + import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; -import { APM_FEATURE } from './feature'; +import { + APM_FEATURE, + APM_SERVICE_MAPS_FEATURE_NAME, + APM_SERVICE_MAPS_LICENSE_TYPE, +} from './feature'; import { apmIndices, apmTelemetry } from './saved_objects'; import { createElasticCloudInstructions } from './tutorial/elastic_cloud'; import { MlPluginSetup } from '../../ml/server'; @@ -120,16 +128,25 @@ export class APMPlugin implements Plugin { elasticCloud: createElasticCloudInstructions(plugins.cloud), }; }); + plugins.features.registerFeature(APM_FEATURE); + plugins.licensing.featureUsage.register( + APM_SERVICE_MAPS_FEATURE_NAME, + APM_SERVICE_MAPS_LICENSE_TYPE + ); - createApmApi().init(core, { - config$: mergedConfig$, - logger: this.logger!, - plugins: { - observability: plugins.observability, - security: plugins.security, - ml: plugins.ml, - }, + core.getStartServices().then(([_coreStart, pluginsStart]) => { + createApmApi().init(core, { + config$: mergedConfig$, + logger: this.logger!, + plugins: { + licensing: (pluginsStart as { licensing: LicensingPluginStart }) + .licensing, + observability: plugins.observability, + security: plugins.security, + ml: plugins.ml, + }, + }); }); return { diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index 3d3e26f680e0d2..f5db936c00d3a7 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -9,6 +9,7 @@ import { CoreSetup, Logger } from 'src/core/server'; import { Params } from '../typings'; import { BehaviorSubject } from 'rxjs'; import { APMConfig } from '../..'; +import { LicensingPluginStart } from '../../../../licensing/server'; const getCoreMock = () => { const get = jest.fn(); @@ -40,7 +41,7 @@ const getCoreMock = () => { logger: ({ error: jest.fn(), } as unknown) as Logger, - plugins: {}, + plugins: { licensing: {} as LicensingPluginStart }, }, }; }; diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 774f1f27435a24..bdfb49fa308289 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -13,6 +13,7 @@ import { errorDistributionRoute, errorGroupsRoute, errorsRoute, + errorRateRoute, } from './errors'; import { serviceAgentNameRoute, @@ -81,6 +82,7 @@ const createApmApi = () => { .add(errorDistributionRoute) .add(errorGroupsRoute) .add(errorsRoute) + .add(errorRateRoute) // Services .add(serviceAgentNameRoute) diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index 1615550027d3cd..97314a9a616611 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -11,6 +11,7 @@ import { getErrorGroup } from '../lib/errors/get_error_group'; import { getErrorGroups } from '../lib/errors/get_error_groups'; import { setupRequest } from '../lib/helpers/setup_request'; import { uiFiltersRt, rangeRt } from './default_api_types'; +import { getErrorRate } from '../lib/errors/get_error_rate'; export const errorsRoute = createRoute(() => ({ path: '/api/apm/services/{serviceName}/errors', @@ -80,3 +81,26 @@ export const errorDistributionRoute = createRoute(() => ({ return getErrorDistribution({ serviceName, groupId, setup }); }, })); + +export const errorRateRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/errors/rate', + params: { + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + t.partial({ + groupId: t.string, + }), + uiFiltersRt, + rangeRt, + ]), + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { params } = context; + const { serviceName } = params.path; + const { groupId } = params.query; + return getErrorRate({ serviceName, groupId, setup }); + }, +})); diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index df0403be7b9759..3937c18b3fe5e0 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -15,6 +15,7 @@ import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; +import { APM_SERVICE_MAPS_FEATURE_NAME } from '../feature'; export const serviceMapRoute = createRoute(() => ({ path: '/api/apm/service-map', @@ -35,6 +36,10 @@ export const serviceMapRoute = createRoute(() => ({ throw Boom.forbidden(invalidLicenseMessage); } + context.plugins.licensing.featureUsage.notifyUsage( + APM_SERVICE_MAPS_FEATURE_NAME + ); + const setup = await setupRequest(context, request); const { query: { serviceName, environment }, diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index bc31cb7a582af2..f30a9d18d7aeab 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -14,10 +14,11 @@ import { import { PickByValue, Optional } from 'utility-types'; import { Observable } from 'rxjs'; import { Server } from 'hapi'; +import { LicensingPluginStart } from '../../../licensing/server'; import { ObservabilityPluginSetup } from '../../../observability/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FetchOptions } from '../../public/services/rest/callApi'; -import { SecurityPluginSetup } from '../../../security/public'; +import { SecurityPluginSetup } from '../../../security/server'; import { MlPluginSetup } from '../../../ml/server'; import { APMConfig } from '..'; @@ -66,6 +67,7 @@ export type APMRequestHandlerContext< config: APMConfig; logger: Logger; plugins: { + licensing: LicensingPluginStart; observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; ml?: MlPluginSetup; @@ -114,6 +116,7 @@ export interface ServerAPI { config$: Observable; logger: Logger; plugins: { + licensing: LicensingPluginStart; observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; ml?: MlPluginSetup; diff --git a/x-pack/plugins/beats_management/server/index.ts b/x-pack/plugins/beats_management/server/index.ts index 607fb0ab2725d3..ad19087f5ac9ff 100644 --- a/x-pack/plugins/beats_management/server/index.ts +++ b/x-pack/plugins/beats_management/server/index.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PluginInitializer } from '../../../../src/core/server'; import { beatsManagementConfigSchema } from '../common'; +import { BeatsManagementPlugin } from './plugin'; export const config = { schema: beatsManagementConfigSchema, @@ -16,8 +18,4 @@ export const config = { }, }; -export const plugin = () => ({ - setup() {}, - start() {}, - stop() {}, -}); +export const plugin: PluginInitializer<{}, {}> = (context) => new BeatsManagementPlugin(context); diff --git a/x-pack/plugins/beats_management/server/plugin.ts b/x-pack/plugins/beats_management/server/plugin.ts new file mode 100644 index 00000000000000..a82dbcb4a3a6ed --- /dev/null +++ b/x-pack/plugins/beats_management/server/plugin.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from '../../../../src/core/server'; +import { SecurityPluginSetup } from '../../security/server'; +import { LicensingPluginStart } from '../../licensing/server'; +import { BeatsManagementConfigType } from '../common'; + +interface SetupDeps { + security?: SecurityPluginSetup; +} + +interface StartDeps { + licensing: LicensingPluginStart; +} + +export class BeatsManagementPlugin implements Plugin<{}, {}, SetupDeps, StartDeps> { + constructor( + private readonly initializerContext: PluginInitializerContext + ) {} + + public async setup(core: CoreSetup, plugins: SetupDeps) { + this.initializerContext.config.create(); + + return {}; + } + + public async start(core: CoreStart, { licensing }: StartDeps) { + return {}; + } +} diff --git a/x-pack/legacy/plugins/beats_management/types/formsy.d.ts b/x-pack/plugins/beats_management/types/formsy.d.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/types/formsy.d.ts rename to x-pack/plugins/beats_management/types/formsy.d.ts diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts index c260d6ca8ac16b..db0417434227c4 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts @@ -13,12 +13,7 @@ import { } from 'src/core/server/mocks'; import { CUSTOM_ELEMENT_TYPE } from '../../../common/lib/constants'; import { initializeCreateCustomElementRoute } from './create'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; const mockRouteContext = ({ core: { @@ -43,7 +38,7 @@ describe('POST custom element', () => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeCreateCustomElementRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts index e70fae5d18eaf2..98b26ec368ab1e 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts @@ -6,12 +6,7 @@ import { CUSTOM_ELEMENT_TYPE } from '../../../common/lib/constants'; import { initializeDeleteCustomElementRoute } from './delete'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { savedObjectsClientMock, httpServiceMock, @@ -32,7 +27,7 @@ describe('DELETE custom element', () => { beforeEach(() => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeDeleteCustomElementRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts index 6644d3b56c6815..dead9ded8a14af 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts @@ -5,12 +5,7 @@ */ import { initializeFindCustomElementsRoute } from './find'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { savedObjectsClientMock, httpServiceMock, @@ -31,7 +26,7 @@ describe('Find custom element', () => { beforeEach(() => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeFindCustomElementsRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts index 03ae6048801bf2..09b620aeff9bb1 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts @@ -6,12 +6,7 @@ import { CUSTOM_ELEMENT_TYPE } from '../../../common/lib/constants'; import { initializeGetCustomElementRoute } from './get'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { savedObjectsClientMock, httpServiceMock, @@ -32,7 +27,7 @@ describe('GET custom element', () => { beforeEach(() => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeGetCustomElementRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts index e67e42c26cd2b6..19477458bacb5a 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts @@ -8,12 +8,7 @@ import sinon from 'sinon'; import { CustomElement } from '../../../types'; import { CUSTOM_ELEMENT_TYPE } from '../../../common/lib/constants'; import { initializeUpdateCustomElementRoute } from './update'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { savedObjectsClientMock, httpServiceMock, @@ -57,7 +52,7 @@ describe('PUT custom element', () => { clock = sinon.useFakeTimers(now); const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeUpdateCustomElementRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts index c3588957ff68e7..93fdb4304acc6d 100644 --- a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts +++ b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts @@ -5,12 +5,7 @@ */ import { initializeESFieldsRoute } from './es_fields'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { httpServiceMock, httpServerMock, @@ -31,7 +26,7 @@ describe('Retrieve ES Fields', () => { beforeEach(() => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeESFieldsRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/shareables/download.test.ts b/x-pack/plugins/canvas/server/routes/shareables/download.test.ts index be4765217d7aa8..75eeb46c890d5d 100644 --- a/x-pack/plugins/canvas/server/routes/shareables/download.test.ts +++ b/x-pack/plugins/canvas/server/routes/shareables/download.test.ts @@ -7,12 +7,7 @@ jest.mock('fs'); import fs from 'fs'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { httpServiceMock, httpServerMock, loggingServiceMock } from 'src/core/server/mocks'; import { initializeDownloadShareableWorkpadRoute } from './download'; @@ -25,7 +20,7 @@ describe('Download Canvas shareables runtime', () => { beforeEach(() => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeDownloadShareableWorkpadRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts b/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts index 63776f897a04ca..5a2d122c2754be 100644 --- a/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts +++ b/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts @@ -7,12 +7,7 @@ jest.mock('archiver'); const archiver = require('archiver') as jest.Mock; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { httpServiceMock, httpServerMock, loggingServiceMock } from 'src/core/server/mocks'; import { initializeZipShareableWorkpadRoute } from './zip'; import { API_ROUTE_SHAREABLE_ZIP } from '../../../common/lib'; @@ -31,7 +26,7 @@ describe('Zips Canvas shareables runtime together with workpad', () => { beforeEach(() => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeZipShareableWorkpadRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/workpad/create.test.ts b/x-pack/plugins/canvas/server/routes/workpad/create.test.ts index 02b6376ece2eda..2ed63e7397108a 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/create.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/create.test.ts @@ -13,12 +13,7 @@ import { } from 'src/core/server/mocks'; import { CANVAS_TYPE } from '../../../common/lib/constants'; import { initializeCreateWorkpadRoute } from './create'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; const mockRouteContext = ({ core: { @@ -43,7 +38,7 @@ describe('POST workpad', () => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeCreateWorkpadRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts b/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts index 57df4e7cffda61..712ff294003829 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts @@ -6,12 +6,7 @@ import { CANVAS_TYPE } from '../../../common/lib/constants'; import { initializeDeleteWorkpadRoute } from './delete'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { savedObjectsClientMock, httpServiceMock, @@ -32,7 +27,7 @@ describe('DELETE workpad', () => { beforeEach(() => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeDeleteWorkpadRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/workpad/find.test.ts b/x-pack/plugins/canvas/server/routes/workpad/find.test.ts index 08de9b20e98185..e2dd8552379b7a 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/find.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/find.test.ts @@ -5,12 +5,7 @@ */ import { initializeFindWorkpadsRoute } from './find'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { savedObjectsClientMock, httpServiceMock, @@ -31,7 +26,7 @@ describe('Find workpad', () => { beforeEach(() => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeFindWorkpadsRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/workpad/get.test.ts b/x-pack/plugins/canvas/server/routes/workpad/get.test.ts index 6741f2b3fc9d67..9ecd9ceefed8d0 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/get.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/get.test.ts @@ -6,12 +6,7 @@ import { CANVAS_TYPE } from '../../../common/lib/constants'; import { initializeGetWorkpadRoute } from './get'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { savedObjectsClientMock, httpServiceMock, @@ -34,7 +29,7 @@ describe('GET workpad', () => { beforeEach(() => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeGetWorkpadRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/canvas/server/routes/workpad/update.test.ts b/x-pack/plugins/canvas/server/routes/workpad/update.test.ts index a6b34e71651215..36ea984447d8ac 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/update.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/update.test.ts @@ -7,12 +7,7 @@ import sinon from 'sinon'; import { CANVAS_TYPE } from '../../../common/lib/constants'; import { initializeUpdateWorkpadRoute, initializeUpdateWorkpadAssetsRoute } from './update'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { savedObjectsClientMock, httpServiceMock, @@ -44,7 +39,7 @@ describe('PUT workpad', () => { clock = sinon.useFakeTimers(now); const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeUpdateWorkpadRoute({ router, logger: loggingServiceMock.create().get(), @@ -158,7 +153,7 @@ describe('update assets', () => { beforeEach(() => { clock = sinon.useFakeTimers(now); const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); initializeUpdateWorkpadAssetsRoute({ router, logger: loggingServiceMock.create().get(), diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index eff91fff32c020..e00c1c111b41b2 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter } from 'kibana/server'; import { loggingServiceMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; import { CaseService, CaseConfigureService } from '../../../services'; import { authenticationMock } from '../__fixtures__'; @@ -16,7 +15,7 @@ export const createRoute = async ( badAuth = false ) => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); const log = loggingServiceMock.create().get('case'); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 81156b98bab838..2da489e643435c 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -222,7 +222,12 @@ describe('Utils', () => { ]; const res = transformCases( - { saved_objects: mockCases, total: mockCases.length, per_page: 10, page: 1 }, + { + saved_objects: mockCases.map((obj) => ({ ...obj, score: 1 })), + total: mockCases.length, + per_page: 10, + page: 1, + }, 2, 2, extraCaseData, @@ -232,7 +237,11 @@ describe('Utils', () => { page: 1, per_page: 10, total: mockCases.length, - cases: flattenCaseSavedObjects(mockCases, extraCaseData, '123'), + cases: flattenCaseSavedObjects( + mockCases.map((obj) => ({ ...obj, score: 1 })), + extraCaseData, + '123' + ), count_open_cases: 2, count_closed_cases: 2, }); @@ -500,7 +509,7 @@ describe('Utils', () => { describe('transformComments', () => { it('transforms correctly', () => { const comments = { - saved_objects: mockCaseComments, + saved_objects: mockCaseComments.map((obj) => ({ ...obj, score: 1 })), total: mockCaseComments.length, per_page: 10, page: 1, diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index b7f3c68d1662fa..ec2881807442fa 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -101,7 +101,7 @@ export const transformCases = ( }); export const flattenCaseSavedObjects = ( - savedObjects: SavedObjectsFindResponse['saved_objects'], + savedObjects: Array>, totalCommentByCase: TotalCommentByCase[], caseConfigureConnectorId: string = 'none' ): CaseResponse[] => @@ -146,7 +146,7 @@ export const transformComments = ( }); export const flattenCommentSavedObjects = ( - savedObjects: SavedObjectsFindResponse['saved_objects'] + savedObjects: Array> ): CommentResponse[] => savedObjects.reduce((acc: CommentResponse[], savedObject: SavedObject) => { return [...acc, flattenCommentSavedObject(savedObject)]; diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_create_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_create_route.test.ts index 0b5f04556596af..cf2349bc7023cc 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_create_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_create_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Create auto-follow pattern', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerCreateRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_delete_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_delete_route.test.ts index 7468c643a3aa6a..b2a3b631333b44 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_delete_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_delete_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Delete auto-follow pattern(s)', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerDeleteRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_fetch_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_fetch_route.test.ts index 1aa7112c752765..4f2417ec816f49 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_fetch_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_fetch_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Fetch all auto-follow patterns', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerFetchRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_get_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_get_route.test.ts index 980128027c2f98..802aebd6412fed 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_get_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_get_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Get one auto-follow pattern', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerGetRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_pause_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_pause_route.test.ts index 5b27c77ca86de6..a92a9b5edb9b79 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_pause_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_pause_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Pause auto-follow pattern(s)', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerPauseRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_resume_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_resume_route.test.ts index afea0f631fe48b..4ee77b1b3deaaa 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_resume_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_resume_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Resume auto-follow pattern(s)', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerResumeRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_update_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_update_route.test.ts index bdce84f6404b15..711538e2a1ee0a 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_update_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern/register_update_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Update auto-follow pattern', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerUpdateRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_create_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_create_route.test.ts index ccf7c469fe7807..de21458c0a25f9 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_create_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_create_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Create follower index', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerCreateRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_fetch_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_fetch_route.test.ts index e1ec28a7c90b10..ec52f1e431e385 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_fetch_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_fetch_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Fetch all follower indices', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerFetchRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_get_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_get_route.test.ts index 99c871d5d4f2dc..a0feeb2b1e5bc2 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_get_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_get_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Get one follower index', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerGetRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_pause_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_pause_route.test.ts index 3d28d36ac61827..dcbec8703622e6 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_pause_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_pause_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Pause follower index/indices', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerPauseRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_resume_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_resume_route.test.ts index 09975b262dca8f..30d25c3bc4d03e 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_resume_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_resume_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Resume follower index/indices', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerResumeRoute({ router, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_unfollow_route.test.ts b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_unfollow_route.test.ts index 5f0d148bfcae96..a56eb8178b4782 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_unfollow_route.test.ts +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index/register_unfollow_route.test.ts @@ -5,7 +5,7 @@ */ import { httpServiceMock, httpServerMock } from 'src/core/server/mocks'; -import { IRouter, kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; import { isEsError } from '../../../shared_imports'; import { formatEsError } from '../../../lib/format_es_error'; @@ -19,7 +19,7 @@ describe('[CCR API] Unfollow follower index/indices', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerUnfollowRoute({ router, diff --git a/x-pack/plugins/dashboard_enhanced/kibana.json b/x-pack/plugins/dashboard_enhanced/kibana.json index 37211ea5371797..3a95419d2f2fe0 100644 --- a/x-pack/plugins/dashboard_enhanced/kibana.json +++ b/x-pack/plugins/dashboard_enhanced/kibana.json @@ -3,6 +3,6 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": ["data", "uiActionsEnhanced", "drilldowns", "embeddable", "dashboard", "share"], + "requiredPlugins": ["data", "uiActionsEnhanced", "embeddable", "dashboard", "share"], "configPath": ["xpack", "dashboardEnhanced"] } diff --git a/x-pack/plugins/dashboard_enhanced/public/plugin.ts b/x-pack/plugins/dashboard_enhanced/public/plugin.ts index 413f5a7afe3564..854a4964ffe156 100644 --- a/x-pack/plugins/dashboard_enhanced/public/plugin.ts +++ b/x-pack/plugins/dashboard_enhanced/public/plugin.ts @@ -10,12 +10,10 @@ import { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embedd import { DashboardDrilldownsService } from './services'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../ui_actions_enhanced/public'; -import { DrilldownsSetup, DrilldownsStart } from '../../drilldowns/public'; import { DashboardStart } from '../../../../src/plugins/dashboard/public'; export interface SetupDependencies { uiActionsEnhanced: AdvancedUiActionsSetup; - drilldowns: DrilldownsSetup; embeddable: EmbeddableSetup; share: SharePluginSetup; } @@ -23,7 +21,6 @@ export interface SetupDependencies { export interface StartDependencies { uiActionsEnhanced: AdvancedUiActionsStart; data: DataPublicPluginStart; - drilldowns: DrilldownsStart; embeddable: EmbeddableStart; share: SharePluginStart; dashboard: DashboardStart; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx index 5ec1b881317d69..712a46dc32e082 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx @@ -9,13 +9,13 @@ import { OpenFlyoutAddDrilldownParams, } from './flyout_create_drilldown'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; -import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; import { TriggerContextMapping } from '../../../../../../../../src/plugins/ui_actions/public'; import { MockEmbeddable, enhanceEmbeddable } from '../test_helpers'; +import { uiActionsEnhancedPluginMock } from '../../../../../../ui_actions_enhanced/public/mocks'; const overlays = coreMock.createStart().overlays; -const drilldowns = drilldownsPluginMock.createStartContract(); +const uiActionsEnhanced = uiActionsEnhancedPluginMock.createStartContract(); const actionParams: OpenFlyoutAddDrilldownParams = { start: () => ({ @@ -23,7 +23,7 @@ const actionParams: OpenFlyoutAddDrilldownParams = { overlays, } as any, plugins: { - drilldowns, + uiActionsEnhanced, }, self: {}, }), diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx index 326cd551c7f84e..4804a700c6cff3 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -16,7 +16,7 @@ import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_ export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; export interface OpenFlyoutAddDrilldownParams { - start: StartServicesGetter>; + start: StartServicesGetter>; } export class FlyoutCreateDrilldownAction implements ActionByType { @@ -62,7 +62,7 @@ export class FlyoutCreateDrilldownAction implements ActionByType handle.close()} viewMode={'create'} dynamicActionManager={embeddable.enhancements.dynamicActions} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx index 309e6cbf53a3db..b9ae45c2853c37 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx @@ -6,14 +6,12 @@ import { FlyoutEditDrilldownAction, FlyoutEditDrilldownParams } from './flyout_edit_drilldown'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; -import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; import { uiActionsEnhancedPluginMock } from '../../../../../../ui_actions_enhanced/public/mocks'; import { EnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public'; import { MockEmbeddable, enhanceEmbeddable } from '../test_helpers'; const overlays = coreMock.createStart().overlays; -const drilldowns = drilldownsPluginMock.createStartContract(); const uiActionsPlugin = uiActionsEnhancedPluginMock.createPlugin(); const uiActions = uiActionsPlugin.doStart(); @@ -32,7 +30,7 @@ const actionParams: FlyoutEditDrilldownParams = { overlays, } as any, plugins: { - drilldowns, + uiActionsEnhanced: uiActions, }, self: {}, }), diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx index 5d2a90fdaff085..af1ae67454463e 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -20,7 +20,7 @@ import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_ export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; export interface FlyoutEditDrilldownParams { - start: StartServicesGetter>; + start: StartServicesGetter>; } export class FlyoutEditDrilldownAction implements ActionByType { @@ -58,7 +58,7 @@ export class FlyoutEditDrilldownAction implements ActionByType handle.close()} viewMode={'manage'} dynamicActionManager={embeddable.enhancements.dynamicActions} diff --git a/x-pack/plugins/drilldowns/README.md b/x-pack/plugins/drilldowns/README.md deleted file mode 100644 index 701b6082d4985f..00000000000000 --- a/x-pack/plugins/drilldowns/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Drilldowns - -Provides functionality to navigate between Kibana apps with context information. diff --git a/x-pack/plugins/drilldowns/kibana.json b/x-pack/plugins/drilldowns/kibana.json deleted file mode 100644 index 1614f94b488fdb..00000000000000 --- a/x-pack/plugins/drilldowns/kibana.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": "drilldowns", - "version": "kibana", - "server": false, - "ui": true, - "requiredPlugins": ["uiActions", "embeddable", "uiActionsEnhanced"], - "configPath": ["xpack", "drilldowns"] -} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts deleted file mode 100644 index 23af89ebf9bc7e..00000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.CloseButtonLabel', { - defaultMessage: 'Close', -}); - -export const txtBack = i18n.translate('xpack.drilldowns.components.FlyoutFrame.BackButtonLabel', { - defaultMessage: 'Back', -}); diff --git a/x-pack/plugins/drilldowns/public/index.ts b/x-pack/plugins/drilldowns/public/index.ts deleted file mode 100644 index f976356822dce7..00000000000000 --- a/x-pack/plugins/drilldowns/public/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { DrilldownsPlugin } from './plugin'; - -export { - SetupContract as DrilldownsSetup, - SetupDependencies as DrilldownsSetupDependencies, - StartContract as DrilldownsStart, - StartDependencies as DrilldownsStartDependencies, -} from './plugin'; - -export function plugin() { - return new DrilldownsPlugin(); -} diff --git a/x-pack/plugins/drilldowns/public/mocks.ts b/x-pack/plugins/drilldowns/public/mocks.ts deleted file mode 100644 index 18816243a3572d..00000000000000 --- a/x-pack/plugins/drilldowns/public/mocks.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { DrilldownsSetup, DrilldownsStart } from '.'; - -export type Setup = jest.Mocked; -export type Start = jest.Mocked; - -const createSetupContract = (): Setup => { - const setupContract: Setup = { - registerDrilldown: jest.fn(), - }; - return setupContract; -}; - -const createStartContract = (): Start => { - const startContract: Start = { - FlyoutManageDrilldowns: jest.fn(), - }; - - return startContract; -}; - -export const drilldownsPluginMock = { - createSetupContract, - createStartContract, -}; diff --git a/x-pack/plugins/drilldowns/public/plugin.ts b/x-pack/plugins/drilldowns/public/plugin.ts deleted file mode 100644 index 2805e2b747934c..00000000000000 --- a/x-pack/plugins/drilldowns/public/plugin.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CoreStart, CoreSetup, Plugin } from 'src/core/public'; -import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../ui_actions_enhanced/public'; -import { createFlyoutManageDrilldowns } from './components/connected_flyout_manage_drilldowns'; -import { Storage } from '../../../../src/plugins/kibana_utils/public'; - -export interface SetupDependencies { - uiActions: UiActionsSetup; - uiActionsEnhanced: AdvancedUiActionsSetup; -} - -export interface StartDependencies { - uiActions: UiActionsStart; - uiActionsEnhanced: AdvancedUiActionsStart; -} - -// eslint-disable-next-line -export interface SetupContract {} - -export interface StartContract { - FlyoutManageDrilldowns: ReturnType; -} - -export class DrilldownsPlugin - implements Plugin { - public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { - return {}; - } - - public start(core: CoreStart, plugins: StartDependencies): StartContract { - return { - FlyoutManageDrilldowns: createFlyoutManageDrilldowns({ - uiActionsEnhanced: plugins.uiActionsEnhanced, - storage: new Storage(localStorage), - notifications: core.notifications, - docsLink: core.docLinks.links.dashboard.drilldowns, - }), - }; - } - - public stop() {} -} diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 7098f611defa04..ec5d81532e238f 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -676,12 +676,14 @@ describe('#find', () => { id: 'some-id', type: 'unknown-type', attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + score: 1, references: [], }, { id: 'some-id-2', type: 'unknown-type', attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + score: 1, references: [], }, ], @@ -722,6 +724,7 @@ describe('#find', () => { attrNotSoSecret: 'not-so-secret', attrThree: 'three', }, + score: 1, references: [], }, { @@ -733,6 +736,7 @@ describe('#find', () => { attrNotSoSecret: '*not-so-secret*', attrThree: 'three', }, + score: 1, references: [], }, ], @@ -793,6 +797,7 @@ describe('#find', () => { attrNotSoSecret: 'not-so-secret', attrThree: 'three', }, + score: 1, references: [], }, { @@ -804,6 +809,7 @@ describe('#find', () => { attrNotSoSecret: '*not-so-secret*', attrThree: 'three', }, + score: 1, references: [], }, ], diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx index 19eec0f0a9f9da..bc4fa67e4658f4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -113,7 +113,7 @@ export const ConfigurationForm = React.memo(({ value }: Props) => { }); return subscription.unsubscribe; - }, [form, dispatch]); + }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (isMounted.current === undefined) { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx index 230e6615bc4a4f..62eb920f8865d0 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx @@ -56,7 +56,7 @@ export const CreateField = React.memo(function CreateFieldComponent({ }); return subscription.unsubscribe; - }, [form, dispatch]); + }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps const cancel = () => { dispatch({ type: 'documentField.changeStatus', value: 'idle' }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx index 534c891a6f3941..d543e49d23be99 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx @@ -32,7 +32,7 @@ export const EditFieldContainer = React.memo(({ field, allFields }: Props) => { }); return subscription.unsubscribe; - }, [form, dispatch]); + }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps const exitEdit = useCallback(() => { dispatch({ type: 'documentField.changeStatus', value: 'idle' }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx index cb0016e967c42b..9d9df38ef4e251 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -25,15 +25,6 @@ export const DocumentFieldsTreeEditor = () => { dispatch({ type: 'documentField.createField' }); }, [dispatch]); - useEffect(() => { - /** - * If there aren't any fields yet, we display the create field form - */ - if (status === 'idle' && fields.length === 0) { - addField(); - } - }, [addField, fields, status]); - const renderCreateField = () => { // The "fieldToAddFieldTo" is undefined when adding to the top level "properties" object. const isCreateFieldFormVisible = status === 'creatingField' && fieldToAddFieldTo === undefined; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx index e6b7eeb12b4c8e..80937e7da11922 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx @@ -69,7 +69,7 @@ export const TemplatesForm = React.memo(({ value }: Props) => { }); }); return subscription.unsubscribe; - }, [form, dispatch]); + }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (isMounted.current === undefined) { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts index 58db8af3f7c5cf..29cfaf99c6559b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts @@ -11,3 +11,5 @@ export * from './mappings_editor'; export * from './components/load_mappings'; export { OnUpdateHandler, Types } from './mappings_state'; + +export { IndexSettings } from './types'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx index 04e0980513b6a2..9e3637f970293d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx @@ -13,7 +13,7 @@ interface Props { children: React.ReactNode; } -export const IndexSettingsProvider = ({ indexSettings, children }: Props) => ( +export const IndexSettingsProvider = ({ indexSettings = {}, children }: Props) => ( {children} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts index 4b610ff0b401df..bc495b05e07b70 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts @@ -6,63 +6,9 @@ jest.mock('../constants', () => ({ MAIN_DATA_TYPE_DEFINITION: {} })); -import { isStateValid, stripUndefinedValues } from './utils'; +import { stripUndefinedValues } from './utils'; describe('utils', () => { - describe('isStateValid()', () => { - let components: any; - it('handles base case', () => { - components = { - fieldsJsonEditor: { isValid: undefined }, - configuration: { isValid: undefined }, - fieldForm: undefined, - }; - expect(isStateValid(components)).toBe(undefined); - }); - - it('handles combinations of true, false and undefined', () => { - components = { - fieldsJsonEditor: { isValid: false }, - configuration: { isValid: true }, - fieldForm: undefined, - }; - - expect(isStateValid(components)).toBe(false); - - components = { - fieldsJsonEditor: { isValid: false }, - configuration: { isValid: undefined }, - fieldForm: undefined, - }; - - expect(isStateValid(components)).toBe(undefined); - - components = { - fieldsJsonEditor: { isValid: true }, - configuration: { isValid: undefined }, - fieldForm: undefined, - }; - - expect(isStateValid(components)).toBe(undefined); - - components = { - fieldsJsonEditor: { isValid: true }, - configuration: { isValid: false }, - fieldForm: undefined, - }; - - expect(isStateValid(components)).toBe(false); - - components = { - fieldsJsonEditor: { isValid: false }, - configuration: { isValid: true }, - fieldForm: { isValid: true }, - }; - - expect(isStateValid(components)).toBe(false); - }); - }); - describe('stripUndefinedValues()', () => { test('should remove all undefined value recursively', () => { const myDate = new Date(); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts index 14f5858cb95d91..9fa4a7981c047d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts @@ -30,7 +30,6 @@ import { MAIN_DATA_TYPE_DEFINITION, } from '../constants'; -import { State } from '../reducer'; import { FieldConfig } from '../shared_imports'; import { TreeItem } from '../components/tree'; @@ -517,24 +516,6 @@ export const shouldDeleteChildFieldsAfterTypeChange = ( export const canUseMappingsEditor = (maxNestedDepth: number) => maxNestedDepth < MAX_DEPTH_DEFAULT_EDITOR; -const stateWithValidity: Array = ['configuration', 'fieldsJsonEditor', 'fieldForm']; - -export const isStateValid = (state: State): boolean | undefined => - Object.entries(state) - .filter(([key]) => stateWithValidity.includes(key as keyof State)) - .reduce((isValid, { 1: value }) => { - if (value === undefined) { - return isValid; - } - - // If one section validity of the state is "undefined", the mappings validity is also "undefined" - if (isValid === undefined || value.isValid === undefined) { - return undefined; - } - - return isValid && value.isValid; - }, true as undefined | boolean); - /** * This helper removes all the keys on an object with an "undefined" value. * To avoid sending updates from the mappings editor with this type of object: diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx index 029b154761ea4a..fb4bfae9740005 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx @@ -8,7 +8,6 @@ import React, { useReducer, useEffect, createContext, useContext, useMemo, useRe import { reducer, - addFieldToState, MappingsConfiguration, MappingsFields, MappingsTemplates, @@ -32,7 +31,7 @@ export interface Types { export interface OnUpdateHandlerArg { isValid?: boolean; - getData: (isValid: boolean) => Mappings; + getData: () => Mappings; validate: () => Promise; } @@ -57,7 +56,7 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) = const parsedFieldsDefaultValue = useMemo(() => normalize(value.fields), [value.fields]); const initialState: State = { - isValid: undefined, + isValid: true, configuration: { defaultValue: value.configuration, data: { @@ -76,7 +75,7 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) = }, fields: parsedFieldsDefaultValue, documentFields: { - status: 'idle', + status: parsedFieldsDefaultValue.rootLevelFields.length === 0 ? 'creatingField' : 'idle', editor: 'default', }, fieldsJsonEditor: { @@ -105,32 +104,15 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) = onChange({ // Output a mappings object from the user's input. - getData: (isValid: boolean) => { - let nextState = state; - - if ( - state.fieldForm && - state.documentFields.status === 'creatingField' && - isValid && - !bypassFieldFormValidation - ) { - // If the form field is valid and we are creating a new field that has some data - // we automatically add the field to our state. - const fieldFormData = state.fieldForm.data.format() as Field; - if (Object.keys(fieldFormData).length !== 0) { - nextState = addFieldToState(fieldFormData, state); - dispatch({ type: 'field.add', value: fieldFormData }); - } - } - + getData: () => { // Pull the mappings properties from the current editor const fields = - nextState.documentFields.editor === 'json' - ? nextState.fieldsJsonEditor.format() - : deNormalize(nextState.fields); + state.documentFields.editor === 'json' + ? state.fieldsJsonEditor.format() + : deNormalize(state.fields); - const configurationData = nextState.configuration.data.format(); - const templatesData = nextState.templates.data.format(); + const configurationData = state.configuration.data.format(); + const templatesData = state.templates.data.format(); return { ...stripUndefinedValues({ @@ -163,9 +145,11 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) = promisesToValidate.push(state.fieldForm.validate()); } - return Promise.all(promisesToValidate).then( - (validationArray) => validationArray.every(Boolean) && state.fieldsJsonEditor.isValid - ); + return Promise.all(promisesToValidate).then((validationArray) => { + const isValid = validationArray.every(Boolean) && state.fieldsJsonEditor.isValid; + dispatch({ type: 'validity:update', value: isValid }); + return isValid; + }); }, isValid: state.isValid, }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts index e0311fc86a3b0c..27f8b12493008a 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts @@ -11,7 +11,6 @@ import { shouldDeleteChildFieldsAfterTypeChange, getAllChildFields, getMaxNestedDepth, - isStateValid, normalize, updateFieldsPathAfterFieldNameChange, searchFields, @@ -106,7 +105,8 @@ export type Action = | { type: 'documentField.changeStatus'; value: DocumentFieldsStatus } | { type: 'documentField.changeEditor'; value: FieldsEditor } | { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } } - | { type: 'search:update'; value: string }; + | { type: 'search:update'; value: string } + | { type: 'validity:update'; value: boolean }; export type Dispatch = (action: Action) => void; @@ -164,7 +164,7 @@ export const addFieldToState = (field: Field, state: State): State => { return { ...state, - isValid: isStateValid(state), + isValid: true, fields: updatedFields, }; }; @@ -293,8 +293,7 @@ export const reducer = (state: State, action: Action): State => { configuration: { ...state.configuration, ...action.value }, }; - const isValid = isStateValid(nextState); - nextState.isValid = isValid; + nextState.isValid = action.value.isValid; return nextState; } case 'configuration.save': { @@ -317,8 +316,7 @@ export const reducer = (state: State, action: Action): State => { templates: { ...state.templates, ...action.value }, }; - const isValid = isStateValid(nextState); - nextState.isValid = isValid; + nextState.isValid = action.value.isValid; return nextState; } @@ -342,8 +340,7 @@ export const reducer = (state: State, action: Action): State => { fieldForm: action.value, }; - const isValid = isStateValid(nextState); - nextState.isValid = isValid; + nextState.isValid = action.value.isValid; return nextState; } @@ -529,7 +526,7 @@ export const reducer = (state: State, action: Action): State => { return { ...state, - isValid: isStateValid(state), + isValid: true, fieldForm: undefined, fields: updatedFields, documentFields: { @@ -577,7 +574,7 @@ export const reducer = (state: State, action: Action): State => { }, }; - nextState.isValid = isStateValid(nextState); + nextState.isValid = action.value.isValid; return nextState; } @@ -590,6 +587,12 @@ export const reducer = (state: State, action: Action): State => { }, }; } + case 'validity:update': { + return { + ...state, + isValid: action.value, + }; + } default: throw new Error(`Action "${action!.type}" not recognized.`); } diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts b/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts index b78087ece94b98..95d1222ad2cc9a 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export { StepLogistics } from './step_logistics'; -export { StepAliases } from './step_aliases'; -export { StepMappings } from './step_mappings'; -export { StepSettings } from './step_settings'; -export { StepReview } from './step_review'; +export { StepLogisticsContainer } from './step_logistics_container'; +export { StepAliasesContainer } from './step_aliases_container'; +export { StepMappingsContainer } from './step_mappings_container'; +export { StepSettingsContainer } from './step_settings_container'; +export { StepReviewContainer } from './step_review_container'; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases.tsx index 50a32787c7a04f..e18846a69b8474 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases.tsx @@ -18,119 +18,119 @@ import { EuiCode, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; + +import { Forms } from '../../../../shared_imports'; import { documentationService } from '../../../services/documentation'; -import { StepProps } from '../types'; import { useJsonStep } from './use_json_step'; -export const StepAliases: React.FunctionComponent = ({ - template, - setDataGetter, - onStepValidityChange, -}) => { - const { content, setContent, error } = useJsonStep({ - prop: 'aliases', - defaultValue: template?.template.aliases, - setDataGetter, - onStepValidityChange, - }); +interface Props { + defaultValue: { [key: string]: any }; + onChange: (content: Forms.Content) => void; +} - return ( -
- - - -

- -

-
+export const StepAliases: React.FunctionComponent = React.memo( + ({ defaultValue, onChange }) => { + const { jsonContent, setJsonContent, error } = useJsonStep({ + defaultValue, + onChange, + }); - + return ( +
+ + + +

+ +

+
- -

+ + + +

+ +

+
+
+ + + -

- -
+ + +
+ + - - + {/* Aliases code editor */} + - - - - - - - {/* Aliases code editor */} - - } - helpText={ - - {JSON.stringify({ - my_alias: {}, - })} - - ), + } + helpText={ + + {JSON.stringify({ + my_alias: {}, + })} + + ), + }} + /> + } + isInvalid={Boolean(error)} + error={error} + fullWidth + > + - } - isInvalid={Boolean(error)} - error={error} - fullWidth - > - { - setContent(updated); - }} - data-test-subj="aliasesEditor" - /> - -
- ); -}; + +
+ ); + } +); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases_container.tsx new file mode 100644 index 00000000000000..634887436f816f --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_aliases_container.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { Forms } from '../../../../shared_imports'; +import { WizardContent } from '../template_form'; +import { StepAliases } from './step_aliases'; + +export const StepAliasesContainer = () => { + const { defaultValue, updateContent } = Forms.useContent('aliases'); + + return ; +}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx index 2f6e055b5d0c6d..d011b4b06546aa 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx @@ -8,9 +8,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiButtonEmpty, EuiSpacer } from ' import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { useForm, Form, getUseField, getFormRow, Field } from '../../../../shared_imports'; +import { useForm, Form, getUseField, getFormRow, Field, Forms } from '../../../../shared_imports'; import { documentationService } from '../../../services/documentation'; -import { StepProps } from '../types'; import { schemas, nameConfig, nameConfigWithoutValidations } from '../template_form_schemas'; // Create or Form components with partial props that are common to all instances @@ -59,96 +58,102 @@ const fieldsMeta = { }, }; -export const StepLogistics: React.FunctionComponent = ({ - template, - isEditing, - setDataGetter, - onStepValidityChange, -}) => { - const { form } = useForm({ - schema: schemas.logistics, - defaultValue: template, - options: { stripEmptyFields: false }, - }); +interface Props { + defaultValue: { [key: string]: any }; + onChange: (content: Forms.Content) => void; + isEditing?: boolean; +} - useEffect(() => { - onStepValidityChange(form.isValid); - }, [form.isValid, onStepValidityChange]); +export const StepLogistics: React.FunctionComponent = React.memo( + ({ defaultValue, isEditing, onChange }) => { + const { form } = useForm({ + schema: schemas.logistics, + defaultValue, + options: { stripEmptyFields: false }, + }); - useEffect(() => { - setDataGetter(form.submit); - }, [form.submit, setDataGetter]); + useEffect(() => { + const validate = async () => { + return (await form.submit()).isValid; + }; + onChange({ + isValid: form.isValid, + validate, + getData: form.getFormData, + }); + }, [form.isValid, onChange]); // eslint-disable-line react-hooks/exhaustive-deps - const { name, indexPatterns, order, version } = fieldsMeta; + const { name, indexPatterns, order, version } = fieldsMeta; - return ( -
- - - -

+ return ( + + + + +

+ +

+
+
+ + + -

-
-
- - - - - - -
- - {/* Name */} - - - - {/* Index patterns */} - - - - {/* Order */} - - - - {/* Version */} - - - - - ); -}; +
+ + + + {/* Name */} + + + + {/* Index patterns */} + + + + {/* Order */} + + + + {/* Version */} + + + + + ); + } +); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics_container.tsx new file mode 100644 index 00000000000000..867ecff799858d --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics_container.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { Forms } from '../../../../shared_imports'; +import { WizardContent } from '../template_form'; +import { StepLogistics } from './step_logistics'; + +interface Props { + isEditing?: boolean; +} + +export const StepLogisticsContainer = ({ isEditing = false }: Props) => { + const { defaultValue, updateContent } = Forms.useContent('logistics'); + + return ( + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx index d74dd435ecdae0..800cb519a9393f 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx @@ -14,99 +14,103 @@ import { EuiSpacer, EuiText, } from '@elastic/eui'; -import { documentationService } from '../../../services/documentation'; -import { StepProps, DataGetterFunc } from '../types'; -import { MappingsEditor, OnUpdateHandler, LoadMappingsFromJsonButton } from '../../mappings_editor'; - -export const StepMappings: React.FunctionComponent = ({ - template, - setDataGetter, - onStepValidityChange, -}) => { - const [mappings, setMappings] = useState(template?.template.mappings); - - const onMappingsEditorUpdate = useCallback( - ({ isValid, getData, validate }) => { - onStepValidityChange(isValid); - const dataGetterFunc: DataGetterFunc = async () => { - const isMappingsValid = isValid === undefined ? await validate() : isValid; - const data = getData(isMappingsValid); - return { - isValid: isMappingsValid, - data: { mappings: data }, - path: 'template', - }; - }; +import { Forms } from '../../../../shared_imports'; +import { documentationService } from '../../../services/documentation'; +import { + MappingsEditor, + OnUpdateHandler, + LoadMappingsFromJsonButton, + IndexSettings, +} from '../../mappings_editor'; - setDataGetter(dataGetterFunc); - }, - [setDataGetter, onStepValidityChange] - ); +interface Props { + defaultValue: { [key: string]: any }; + onChange: (content: Forms.Content) => void; + indexSettings?: IndexSettings; +} - const onJsonLoaded = (json: { [key: string]: any }): void => { - setMappings(json); - }; +export const StepMappings: React.FunctionComponent = React.memo( + ({ defaultValue, onChange, indexSettings }) => { + const [mappings, setMappings] = useState(defaultValue); - return ( -
- - - -

- -

-
+ const onMappingsEditorUpdate = useCallback( + ({ isValid, getData, validate }) => { + onChange({ + isValid, + async validate() { + return isValid === undefined ? await validate() : isValid; + }, + getData, + }); + }, + [onChange] + ); - + const onJsonLoaded = (json: { [key: string]: any }): void => { + setMappings(json); + }; - -

- -

-
-
+ return ( +
+ + + +

+ +

+
- - - - - + - - + +

- - - - - +

+
+
+ + + + + + + + + + + + + + +
- + - {/* Mappings code editor */} - + {/* Mappings editor */} + - -
- ); -}; + +
+ ); + } +); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings_container.tsx new file mode 100644 index 00000000000000..545aec9851592e --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings_container.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { Forms } from '../../../../shared_imports'; +import { WizardContent } from '../template_form'; +import { StepMappings } from './step_mappings'; + +export const StepMappingsContainer = () => { + const { defaultValue, updateContent, getData } = Forms.useContent('mappings'); + + return ( + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx index 387887239aaf0b..7f301b0a9c282c 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx @@ -27,7 +27,7 @@ import { serializeTemplate, } from '../../../../../common/lib/template_serialization'; import { TemplateDeserialized, getTemplateParameter } from '../../../../../common'; -import { StepProps } from '../types'; +import { WizardSection } from '../template_form'; const { stripEmptyFields } = serializers; @@ -54,221 +54,228 @@ const getDescriptionText = (data: any) => { ); }; -export const StepReview: React.FunctionComponent = ({ template, updateCurrentStep }) => { - const { - name, - indexPatterns, - version, - order, - _kbnMeta: { isLegacy }, - } = template!; +interface Props { + template: TemplateDeserialized; + navigateToStep: (stepId: WizardSection) => void; +} - const serializedTemplate = isLegacy - ? serializeLegacyTemplate( - stripEmptyFields(template!, { - types: ['string'], - }) as TemplateDeserialized - ) - : serializeTemplate( - stripEmptyFields(template!, { - types: ['string'], - }) as TemplateDeserialized - ); +export const StepReview: React.FunctionComponent = React.memo( + ({ template, navigateToStep }) => { + const { + name, + indexPatterns, + version, + order, + _kbnMeta: { isLegacy }, + } = template!; - const serializedMappings = getTemplateParameter(serializedTemplate, 'mappings'); - const serializedSettings = getTemplateParameter(serializedTemplate, 'settings'); - const serializedAliases = getTemplateParameter(serializedTemplate, 'aliases'); + const serializedTemplate = isLegacy + ? serializeLegacyTemplate( + stripEmptyFields(template!, { + types: ['string'], + }) as TemplateDeserialized + ) + : serializeTemplate( + stripEmptyFields(template!, { + types: ['string'], + }) as TemplateDeserialized + ); - const numIndexPatterns = indexPatterns!.length; + const serializedMappings = getTemplateParameter(serializedTemplate, 'mappings'); + const serializedSettings = getTemplateParameter(serializedTemplate, 'settings'); + const serializedAliases = getTemplateParameter(serializedTemplate, 'aliases'); - const hasWildCardIndexPattern = Boolean(indexPatterns!.find((pattern) => pattern === '*')); + const numIndexPatterns = indexPatterns!.length; - const SummaryTab = () => ( -
- + const hasWildCardIndexPattern = Boolean(indexPatterns!.find((pattern) => pattern === '*')); - - - - - - - - {numIndexPatterns > 1 ? ( - -
    - {indexPatterns!.map((indexName: string, i: number) => { - return ( -
  • - - {indexName} - -
  • - ); - })} -
-
- ) : ( - indexPatterns!.toString() - )} -
+ const SummaryTab = () => ( +
+ - - - - - {order ? order : } - + + + + + + + + {numIndexPatterns > 1 ? ( + +
    + {indexPatterns!.map((indexName: string, i: number) => { + return ( +
  • + + {indexName} + +
  • + ); + })} +
+
+ ) : ( + indexPatterns!.toString() + )} +
- - - - - {version ? version : } - -
-
+ + + + + {order ? order : } + - - - - - - - {getDescriptionText(serializedSettings)} - - - - - - {getDescriptionText(serializedMappings)} - - + + + + + {version ? version : } + + + + + + + + + + + {getDescriptionText(serializedSettings)} + + + + + + {getDescriptionText(serializedMappings)} + + + + + + {getDescriptionText(serializedAliases)} + + + +
+
+ ); + + const RequestTab = () => { + const endpoint = `PUT _template/${name || ''}`; + const templateString = JSON.stringify(serializedTemplate, null, 2); + const request = `${endpoint}\n${templateString}`; + + // Beyond a certain point, highlighting the syntax will bog down performance to unacceptable + // levels. This way we prevent that happening for very large requests. + const language = request.length < 60000 ? 'json' : undefined; + + return ( +
+ + + +

- - - {getDescriptionText(serializedAliases)} - - - - -

- ); +

+ - const RequestTab = () => { - const endpoint = `PUT _template/${name || ''}`; - const templateString = JSON.stringify(serializedTemplate, null, 2); - const request = `${endpoint}\n${templateString}`; + - // Beyond a certain point, highlighting the syntax will bog down performance to unacceptable - // levels. This way we prevent that happening for very large requests. - const language = request.length < 60000 ? 'json' : undefined; + + {request} + +
+ ); + }; return ( -
- - - -

+

+ +

-

- +

+
- - - - {request} - -
- ); - }; + - return ( -
- -

- -

-
- - - - {hasWildCardIndexPattern ? ( - - - } - color="warning" - iconType="help" - data-test-subj="indexPatternsWarning" - > -

- {' '} - {/* Edit link navigates back to step 1 (logistics) */} - + {hasWildCardIndexPattern ? ( + + - -

-
- -
- ) : null} + } + color="warning" + iconType="help" + data-test-subj="indexPatternsWarning" + > +

+ {' '} + {/* Edit link navigates back to step 1 (logistics) */} + + + +

+ + + + ) : null} - , - }, - { - id: 'request', - name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.requestTabTitle', { - defaultMessage: 'Request', - }), - content: , - }, - ]} - /> -
- ); -}; + , + }, + { + id: 'request', + name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.requestTabTitle', { + defaultMessage: 'Request', + }), + content: , + }, + ]} + /> +
+ ); + } +); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review_container.tsx new file mode 100644 index 00000000000000..cafa8660b11503 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review_container.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { TemplateDeserialized } from '../../../../../common'; +import { Forms } from '../../../../shared_imports'; +import { WizardContent, WizardSection } from '../template_form'; +import { StepReview } from './step_review'; + +interface Props { + getTemplateData: (wizardContent: WizardContent) => TemplateDeserialized; +} + +export const StepReviewContainer = React.memo(({ getTemplateData }: Props) => { + const { navigateToStep } = Forms.useFormWizardContext(); + const { getData } = Forms.useMultiContentContext(); + + const wizardContent = getData(); + // Build the final template object, providing the wizard content data + const template = getTemplateData(wizardContent); + + return ; +}); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings.tsx index 7c1ee6388a6182..4325852d68aaa2 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings.tsx @@ -18,111 +18,113 @@ import { EuiCode, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; + +import { Forms } from '../../../../shared_imports'; import { documentationService } from '../../../services/documentation'; -import { StepProps } from '../types'; import { useJsonStep } from './use_json_step'; -export const StepSettings: React.FunctionComponent = ({ - template, - setDataGetter, - onStepValidityChange, -}) => { - const { content, setContent, error } = useJsonStep({ - prop: 'settings', - defaultValue: template?.template.settings, - setDataGetter, - onStepValidityChange, - }); +interface Props { + defaultValue: { [key: string]: any }; + onChange: (content: Forms.Content) => void; +} - return ( -
- - - -

- -

-
+export const StepSettings: React.FunctionComponent = React.memo( + ({ defaultValue, onChange }) => { + const { jsonContent, setJsonContent, error } = useJsonStep({ + defaultValue, + onChange, + }); - + return ( +
+ + + +

+ +

+
- -

+ + + +

+ +

+
+
+ + + -

- -
+ + +
+ + - - + {/* Settings code editor */} + - - - - - - - {/* Settings code editor */} - - } - helpText={ - {JSON.stringify({ number_of_replicas: 1 })}, + } + helpText={ + {JSON.stringify({ number_of_replicas: 1 })}, + }} + /> + } + isInvalid={Boolean(error)} + error={error} + fullWidth + > + - } - isInvalid={Boolean(error)} - error={error} - fullWidth - > - setContent(updated)} - data-test-subj="settingsEditor" - /> - -
- ); -}; + +
+ ); + } +); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings_container.tsx new file mode 100644 index 00000000000000..4d7de644a1442b --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_settings_container.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { Forms } from '../../../../shared_imports'; +import { WizardContent } from '../template_form'; +import { StepSettings } from './step_settings'; + +export const StepSettingsContainer = React.memo(() => { + const { defaultValue, updateContent } = Forms.useContent('settings'); + + return ; +}); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/use_json_step.ts b/x-pack/plugins/index_management/public/application/components/template_form/steps/use_json_step.ts index 25dbe784db3a1f..4c1b36e3abba52 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/use_json_step.ts +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/use_json_step.ts @@ -7,31 +7,23 @@ import { useEffect, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { isJSON } from '../../../../shared_imports'; -import { StepProps, DataGetterFunc } from '../types'; +import { isJSON, Forms } from '../../../../shared_imports'; interface Parameters { - prop: 'settings' | 'mappings' | 'aliases'; - setDataGetter: StepProps['setDataGetter']; - onStepValidityChange: StepProps['onStepValidityChange']; + onChange: (content: Forms.Content) => void; defaultValue?: object; } const stringifyJson = (json: any) => Object.keys(json).length ? JSON.stringify(json, null, 2) : '{\n\n}'; -export const useJsonStep = ({ - prop, - defaultValue = {}, - setDataGetter, - onStepValidityChange, -}: Parameters) => { - const [content, setContent] = useState(stringifyJson(defaultValue)); +export const useJsonStep = ({ defaultValue, onChange }: Parameters) => { + const [jsonContent, setJsonContent] = useState(stringifyJson(defaultValue ?? {})); const [error, setError] = useState(null); const validateContent = useCallback(() => { // We allow empty string as it will be converted to "{}"" - const isValid = content.trim() === '' ? true : isJSON(content); + const isValid = jsonContent.trim() === '' ? true : isJSON(jsonContent); if (!isValid) { setError( i18n.translate('xpack.idxMgmt.validators.string.invalidJSONError', { @@ -42,26 +34,28 @@ export const useJsonStep = ({ setError(null); } return isValid; - }, [content]); + }, [jsonContent]); - const dataGetter = useCallback(() => { + useEffect(() => { const isValid = validateContent(); - const value = isValid && content.trim() !== '' ? JSON.parse(content) : {}; - // If no key has been added to the JSON object, we strip it out so an empty object is not sent in the request - const data = { [prop]: Object.keys(value).length > 0 ? value : undefined }; + const getData = () => { + const value = isValid && jsonContent.trim() !== '' ? JSON.parse(jsonContent) : {}; + // If no key has been added to the JSON object, we strip it out so an empty object is not sent in the request + return Object.keys(value).length > 0 ? value : undefined; + }; - return Promise.resolve({ isValid, data, path: 'template' }); - }, [content, validateContent, prop]); + const content = { + isValid, + validate: async () => isValid, + getData, + }; - useEffect(() => { - const isValid = validateContent(); - onStepValidityChange(isValid); - setDataGetter(dataGetter); - }, [content, dataGetter, onStepValidityChange, setDataGetter, validateContent]); + onChange(content); + }, [jsonContent, onChange, validateContent]); return { - content, - setContent, + jsonContent, + setJsonContent, error, }; }; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index 52e26e6d3e8959..9e6d49faac5630 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -3,25 +3,24 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useState, useRef, useCallback } from 'react'; +import React, { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiSpacer, -} from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; -import { serializers } from '../../../shared_imports'; import { TemplateDeserialized, CREATE_LEGACY_TEMPLATE_BY_DEFAULT } from '../../../../common'; -import { TemplateSteps } from './template_steps'; -import { StepAliases, StepLogistics, StepMappings, StepSettings, StepReview } from './steps'; -import { StepProps, DataGetterFunc } from './types'; +import { serializers, Forms } from '../../../shared_imports'; import { SectionError } from '../section_error'; +import { + StepLogisticsContainer, + StepSettingsContainer, + StepMappingsContainer, + StepAliasesContainer, + StepReviewContainer, +} from './steps'; const { stripEmptyFields } = serializers; +const { FormWizard, FormWizardStep } = Forms; interface Props { onSave: (template: TemplateDeserialized) => void; @@ -32,244 +31,172 @@ interface Props { isEditing?: boolean; } -interface ValidationState { - [key: number]: { isValid: boolean | undefined }; +export interface WizardContent { + logistics: Omit; + settings: TemplateDeserialized['template']['settings']; + mappings: TemplateDeserialized['template']['mappings']; + aliases: TemplateDeserialized['template']['aliases']; } -const defaultValidation = { isValid: true }; +export type WizardSection = keyof WizardContent | 'review'; -const stepComponentMap: { [key: number]: React.FunctionComponent } = { - 1: StepLogistics, - 2: StepSettings, - 3: StepMappings, - 4: StepAliases, - 5: StepReview, +const wizardSections: { [id: string]: { id: WizardSection; label: string } } = { + logistics: { + id: 'logistics', + label: i18n.translate('xpack.idxMgmt.templateForm.steps.logisticsStepName', { + defaultMessage: 'Logistics', + }), + }, + settings: { + id: 'settings', + label: i18n.translate('xpack.idxMgmt.templateForm.steps.settingsStepName', { + defaultMessage: 'Index settings', + }), + }, + mappings: { + id: 'mappings', + label: i18n.translate('xpack.idxMgmt.templateForm.steps.mappingsStepName', { + defaultMessage: 'Mappings', + }), + }, + aliases: { + id: 'aliases', + label: i18n.translate('xpack.idxMgmt.templateForm.steps.aliasesStepName', { + defaultMessage: 'Aliases', + }), + }, + review: { + id: 'review', + label: i18n.translate('xpack.idxMgmt.templateForm.steps.summaryStepName', { + defaultMessage: 'Review template', + }), + }, }; -export const TemplateForm: React.FunctionComponent = ({ +export const TemplateForm = ({ defaultValue = { name: '', indexPatterns: [], - template: {}, + template: { + settings: {}, + mappings: {}, + aliases: {}, + }, _kbnMeta: { isManaged: false, isLegacy: CREATE_LEGACY_TEMPLATE_BY_DEFAULT, }, }, - onSave, + isEditing, isSaving, saveError, clearSaveError, - isEditing, -}) => { - const [currentStep, setCurrentStep] = useState(1); - const [validation, setValidation] = useState({ - 1: defaultValidation, - 2: defaultValidation, - 3: defaultValidation, - 4: defaultValidation, - 5: defaultValidation, - }); - - const template = useRef(defaultValue); - const stepsDataGetters = useRef>({}); - - const lastStep = Object.keys(stepComponentMap).length; - const CurrentStepComponent = stepComponentMap[currentStep]; - const isStepValid = validation[currentStep].isValid; - - const setStepDataGetter = useCallback( - (stepDataGetter: DataGetterFunc) => { - stepsDataGetters.current[currentStep] = stepDataGetter; - }, - [currentStep] - ); - - const onStepValidityChange = useCallback( - (isValid: boolean | undefined) => { - setValidation((prev) => ({ - ...prev, - [currentStep]: { - isValid, - errors: {}, - }, - })); - }, - [currentStep] - ); - - const validateAndGetDataFromCurrentStep = async () => { - const validateAndGetStepData = stepsDataGetters.current[currentStep]; - - if (!validateAndGetStepData) { - throw new Error(`No data getter has been set for step "${currentStep}"`); - } - - const { isValid, data, path } = await validateAndGetStepData(); - - if (isValid) { - // Update the template object with the current step data - if (path) { - // We only update a "slice" of the template - const sliceToUpdate = template.current[path as keyof TemplateDeserialized]; - - if (sliceToUpdate === null || typeof sliceToUpdate !== 'object') { - return { isValid, data }; - } - - template.current = { - ...template.current, - [path]: { ...sliceToUpdate, ...data }, - }; - } else { - template.current = { ...template.current, ...data }; - } - } - - return { isValid, data }; - }; - - const updateCurrentStep = async (nextStep: number) => { - // All steps needs validation, except for the last step - const shouldValidate = currentStep !== lastStep; - - if (shouldValidate) { - const isValid = - isStepValid === false ? false : (await validateAndGetDataFromCurrentStep()).isValid; - - // If step is invalid do not let user proceed - if (!isValid) { - return; - } - } - - setCurrentStep(nextStep); - clearSaveError(); - }; - - const onBack = () => { - const prevStep = currentStep - 1; - updateCurrentStep(prevStep); - }; - - const onNext = () => { - const nextStep = currentStep + 1; - updateCurrentStep(nextStep); + onSave, +}: Props) => { + const { + template: { settings, mappings, aliases }, + _kbnMeta, + ...logistics + } = defaultValue; + + const wizardDefaultValue: WizardContent = { + logistics, + settings, + mappings, + aliases, }; - const saveButtonLabel = isEditing ? ( - - ) : ( - - ); - - return ( - - + ) : ( + + ), + }; - - - {saveError ? ( - - - } - error={saveError} - data-test-subj="saveTemplateError" + const apiError = saveError ? ( + <> + - - - ) : null} - - - - + } + error={saveError} + data-test-subj="saveTemplateError" + /> + + + ) : null; + + const buildTemplateObject = (initialTemplate: TemplateDeserialized) => ( + wizardData: WizardContent + ): TemplateDeserialized => ({ + ...initialTemplate, + ...wizardData.logistics, + template: { + settings: wizardData.settings, + mappings: wizardData.mappings, + aliases: wizardData.aliases, + }, + }); - - - - {currentStep > 1 ? ( - - - - - - ) : null} + const onSaveTemplate = useCallback( + async (wizardData: WizardContent) => { + const template = buildTemplateObject(defaultValue)(wizardData); - {currentStep < lastStep ? ( - - - - - - ) : null} + // We need to strip empty string, otherwise if the "order" or "version" + // are not set, they will be empty string and ES expect a number for those parameters. + onSave( + stripEmptyFields(template, { + types: ['string'], + }) as TemplateDeserialized + ); - {currentStep === lastStep ? ( - - - {isSaving ? ( - - ) : ( - saveButtonLabel - )} - - - ) : null} - - - - + clearSaveError(); + }, + [defaultValue, onSave, clearSaveError] + ); - - + return ( + + defaultValue={wizardDefaultValue} + onSave={onSaveTemplate} + isEditing={isEditing} + isSaving={isSaving} + apiError={apiError} + texts={i18nTexts} + > + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_steps.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_steps.tsx deleted file mode 100644 index 7a31c74c1a9c2e..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_steps.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiStepsHorizontal } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -interface Props { - currentStep: number; - updateCurrentStep: (step: number, maxCompletedStep: number) => void; - isCurrentStepValid: boolean | undefined; -} - -const stepNamesMap: { [key: number]: string } = { - 1: i18n.translate('xpack.idxMgmt.templateForm.steps.logisticsStepName', { - defaultMessage: 'Logistics', - }), - 2: i18n.translate('xpack.idxMgmt.templateForm.steps.settingsStepName', { - defaultMessage: 'Index settings', - }), - 3: i18n.translate('xpack.idxMgmt.templateForm.steps.mappingsStepName', { - defaultMessage: 'Mappings', - }), - 4: i18n.translate('xpack.idxMgmt.templateForm.steps.aliasesStepName', { - defaultMessage: 'Aliases', - }), - 5: i18n.translate('xpack.idxMgmt.templateForm.steps.summaryStepName', { - defaultMessage: 'Review template', - }), -}; - -export const TemplateSteps: React.FunctionComponent = ({ - currentStep, - updateCurrentStep, - isCurrentStepValid, -}) => { - const steps = [1, 2, 3, 4, 5].map((step) => { - return { - title: stepNamesMap[step], - isComplete: currentStep > step, - isSelected: currentStep === step, - disabled: step !== currentStep && isCurrentStepValid === false, - onClick: () => updateCurrentStep(step, step - 1), - }; - }); - - return ; -}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/types.ts b/x-pack/plugins/index_management/public/application/components/template_form/types.ts deleted file mode 100644 index 5db53e91ed261e..00000000000000 --- a/x-pack/plugins/index_management/public/application/components/template_form/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { TemplateDeserialized } from '../../../../common'; - -export interface StepProps { - template?: TemplateDeserialized; - setDataGetter: (dataGetter: DataGetterFunc) => void; - updateCurrentStep: (step: number) => void; - onStepValidityChange: (isValid: boolean | undefined) => void; - isEditing?: boolean; -} - -export type DataGetterFunc = () => Promise<{ - /** Is the step data valid or not */ - isValid: boolean; - /** The current step data (can be invalid) */ - data: any; - /** Optional "slice" of the complete object the step is updating */ - path?: string; -}>; diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index afd5a5cf650e1e..69cd07ba6dba01 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -10,6 +10,7 @@ export { UseRequestConfig, sendRequest, useRequest, + Forms, } from '../../../../src/plugins/es_ui_shared/public/'; export { diff --git a/x-pack/plugins/index_management/server/routes/api/component_templates/privileges.test.ts b/x-pack/plugins/index_management/server/routes/api/component_templates/privileges.test.ts index b34ffe3e0baf5a..060a423350ada4 100644 --- a/x-pack/plugins/index_management/server/routes/api/component_templates/privileges.test.ts +++ b/x-pack/plugins/index_management/server/routes/api/component_templates/privileges.test.ts @@ -4,12 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { httpServerMock, httpServiceMock } from 'src/core/server/mocks'; -import { - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, - IRouter, -} from 'src/core/server'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; import { License } from '../../../services/license'; import { IndexDataEnricher } from '../../../services/index_data_enricher'; @@ -46,7 +41,7 @@ describe('GET privileges', () => { let routeHandler: RequestHandler; beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerPrivilegesRoute({ router, @@ -115,7 +110,7 @@ describe('GET privileges', () => { describe('With security disabled', () => { beforeEach(() => { - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); registerPrivilegesRoute({ router, diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx index a3a48d477425b8..9e4e78ca392fd0 100644 --- a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx @@ -239,4 +239,4 @@ export const Editor: React.FC = (props) => { // required for dynamic import // eslint-disable-next-line import/no-default-export -export default Editor; +export default ExpressionEditor; diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index 14c53557ba2c74..78b7f86993cbde 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { Route, Switch } from 'react-router-dom'; @@ -32,6 +32,8 @@ export const LogsPageContent: React.FunctionComponent = () => { const { initialize } = useLogSourceContext(); + const kibana = useKibana(); + useMount(() => { initialize(); }); @@ -88,6 +90,16 @@ export const LogsPageContent: React.FunctionComponent = () => { + + + {ADD_DATA_LABEL} + + @@ -123,3 +135,7 @@ const settingsTabTitle = i18n.translate('xpack.infra.logs.index.settingsTabTitle }); const feedbackLinkUrl = 'https://discuss.elastic.co/c/logs'; + +const ADD_DATA_LABEL = i18n.translate('xpack.infra.logsHeaderAddDataButtonLabel', { + defaultMessage: 'Add data', +}); diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 35a6cadc786f61..05296fbf6b0a32 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { EuiErrorBoundary, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { EuiErrorBoundary, EuiFlexItem, EuiFlexGroup, EuiButtonEmpty } from '@elastic/eui'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; import { RoutedTabs } from '../../components/navigation/routed_tabs'; @@ -32,9 +32,15 @@ import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters import { InventoryAlertDropdown } from '../../components/alerting/inventory/alert_dropdown'; import { MetricsAlertDropdown } from '../../alerting/metric_threshold/components/alert_dropdown'; +const ADD_DATA_LABEL = i18n.translate('xpack.infra.metricsHeaderAddDataButtonLabel', { + defaultMessage: 'Add data', +}); + export const InfrastructurePage = ({ match }: RouteComponentProps) => { const uiCapabilities = useKibana().services.application?.capabilities; + const kibana = useKibana(); + return ( @@ -102,6 +108,18 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { + + + {ADD_DATA_LABEL} + + diff --git a/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts b/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts index 9dfd76b9ddd211..a3bef72e8db5a3 100644 --- a/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts +++ b/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts @@ -11,10 +11,11 @@ const CONFIG_KEYS_ORDER = [ 'name', 'revision', 'type', - 'outputs', 'settings', - 'datasources', + 'outputs', + 'inputs', 'enabled', + 'use_output', 'package', 'input', ]; diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts deleted file mode 100644 index d319ba2beddf94..00000000000000 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { Datasource, DatasourceInput } from '../types'; -import { storedDatasourceToAgentDatasource } from './datasource_to_agent_datasource'; - -describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { - const mockDatasource: Datasource = { - id: 'some-uuid', - name: 'mock-datasource', - description: '', - created_at: '', - created_by: '', - updated_at: '', - updated_by: '', - config_id: '', - enabled: true, - output_id: '', - namespace: 'default', - inputs: [], - revision: 1, - }; - - const mockInput: DatasourceInput = { - type: 'test-logs', - enabled: true, - vars: { - inputVar: { value: 'input-value' }, - inputVar2: { value: undefined }, - inputVar3: { - type: 'yaml', - value: 'testField: test', - }, - inputVar4: { value: '' }, - }, - streams: [ - { - id: 'test-logs-foo', - enabled: true, - dataset: 'foo', - vars: { - fooVar: { value: 'foo-value' }, - fooVar2: { value: [1, 2] }, - }, - agent_stream: { - fooKey: 'fooValue1', - fooKey2: ['fooValue2'], - }, - }, - { - id: 'test-logs-bar', - enabled: true, - dataset: 'bar', - vars: { - barVar: { value: 'bar-value' }, - barVar2: { value: [1, 2] }, - barVar3: { - type: 'yaml', - value: - '- namespace: mockNamespace\n #disabledProp: ["test"]\n anotherProp: test\n- namespace: mockNamespace2\n #disabledProp: ["test2"]\n anotherProp: test2', - }, - barVar4: { - type: 'yaml', - value: '', - }, - barVar5: { - type: 'yaml', - value: 'testField: test\n invalidSpacing: foo', - }, - }, - }, - ], - }; - - it('returns agent datasource config for datasource with no inputs', () => { - expect(storedDatasourceToAgentDatasource(mockDatasource)).toEqual({ - id: 'some-uuid', - name: 'mock-datasource', - namespace: 'default', - enabled: true, - use_output: 'default', - inputs: [], - }); - - expect( - storedDatasourceToAgentDatasource({ - ...mockDatasource, - package: { - name: 'mock-package', - title: 'Mock package', - version: '0.0.0', - }, - }) - ).toEqual({ - id: 'some-uuid', - name: 'mock-datasource', - namespace: 'default', - enabled: true, - use_output: 'default', - package: { - name: 'mock-package', - version: '0.0.0', - }, - inputs: [], - }); - }); - - it('returns agent datasource config with flattened input and package stream', () => { - expect(storedDatasourceToAgentDatasource({ ...mockDatasource, inputs: [mockInput] })).toEqual({ - id: 'some-uuid', - name: 'mock-datasource', - namespace: 'default', - enabled: true, - use_output: 'default', - inputs: [ - { - type: 'test-logs', - enabled: true, - streams: [ - { - id: 'test-logs-foo', - enabled: true, - dataset: 'foo', - fooKey: 'fooValue1', - fooKey2: ['fooValue2'], - }, - { - id: 'test-logs-bar', - enabled: true, - dataset: 'bar', - }, - ], - }, - ], - }); - }); - - it('returns agent datasource config without disabled streams', () => { - expect( - storedDatasourceToAgentDatasource({ - ...mockDatasource, - inputs: [ - { - ...mockInput, - streams: [{ ...mockInput.streams[0] }, { ...mockInput.streams[1], enabled: false }], - }, - ], - }) - ).toEqual({ - id: 'some-uuid', - name: 'mock-datasource', - namespace: 'default', - enabled: true, - use_output: 'default', - inputs: [ - { - type: 'test-logs', - enabled: true, - streams: [ - { - id: 'test-logs-foo', - enabled: true, - dataset: 'foo', - fooKey: 'fooValue1', - fooKey2: ['fooValue2'], - }, - ], - }, - ], - }); - }); - - it('returns agent datasource config without disabled inputs', () => { - expect( - storedDatasourceToAgentDatasource({ - ...mockDatasource, - inputs: [{ ...mockInput, enabled: false }], - }) - ).toEqual({ - id: 'some-uuid', - name: 'mock-datasource', - namespace: 'default', - enabled: true, - use_output: 'default', - inputs: [], - }); - }); -}); diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts deleted file mode 100644 index 2a8b687675bf9e..00000000000000 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { Datasource, FullAgentConfigDatasource } from '../types'; -import { DEFAULT_OUTPUT } from '../constants'; - -export const storedDatasourceToAgentDatasource = ( - datasource: Datasource -): FullAgentConfigDatasource => { - const { id, name, namespace, enabled, package: pkg, inputs } = datasource; - - const fullDatasource: FullAgentConfigDatasource = { - id: id || name, - name, - namespace, - enabled, - use_output: DEFAULT_OUTPUT.name, // TODO: hardcoded to default output for now - inputs: inputs - .filter((input) => input.enabled) - .map((input) => { - const fullInput = { - ...input, - ...Object.entries(input.config || {}).reduce((acc, [key, { value }]) => { - acc[key] = value; - return acc; - }, {} as { [k: string]: any }), - streams: input.streams - .filter((stream) => stream.enabled) - .map((stream) => { - const fullStream = { - ...stream, - ...stream.agent_stream, - ...Object.entries(stream.config || {}).reduce((acc, [key, { value }]) => { - acc[key] = value; - return acc; - }, {} as { [k: string]: any }), - }; - delete fullStream.agent_stream; - delete fullStream.vars; - delete fullStream.config; - return fullStream; - }), - }; - delete fullInput.vars; - delete fullInput.config; - return fullInput; - }), - }; - - if (pkg) { - fullDatasource.package = { - name: pkg.name, - version: pkg.version, - }; - } - - return fullDatasource; -}; diff --git a/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.test.ts b/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.test.ts new file mode 100644 index 00000000000000..df94168ec88d02 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.test.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Datasource, DatasourceInput } from '../types'; +import { storedDatasourcesToAgentInputs } from './datasources_to_agent_inputs'; + +describe('Ingest Manager - storedDatasourcesToAgentInputs', () => { + const mockDatasource: Datasource = { + id: 'some-uuid', + name: 'mock-datasource', + description: '', + created_at: '', + created_by: '', + updated_at: '', + updated_by: '', + config_id: '', + enabled: true, + output_id: '', + namespace: 'default', + inputs: [], + revision: 1, + }; + + const mockInput: DatasourceInput = { + type: 'test-logs', + enabled: true, + vars: { + inputVar: { value: 'input-value' }, + inputVar2: { value: undefined }, + inputVar3: { + type: 'yaml', + value: 'testField: test', + }, + inputVar4: { value: '' }, + }, + streams: [ + { + id: 'test-logs-foo', + enabled: true, + dataset: 'foo', + vars: { + fooVar: { value: 'foo-value' }, + fooVar2: { value: [1, 2] }, + }, + agent_stream: { + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], + }, + }, + { + id: 'test-logs-bar', + enabled: true, + dataset: 'bar', + vars: { + barVar: { value: 'bar-value' }, + barVar2: { value: [1, 2] }, + barVar3: { + type: 'yaml', + value: + '- namespace: mockNamespace\n #disabledProp: ["test"]\n anotherProp: test\n- namespace: mockNamespace2\n #disabledProp: ["test2"]\n anotherProp: test2', + }, + barVar4: { + type: 'yaml', + value: '', + }, + barVar5: { + type: 'yaml', + value: 'testField: test\n invalidSpacing: foo', + }, + }, + }, + ], + }; + + it('returns no inputs for datasource with no inputs, or only disabled inputs', () => { + expect(storedDatasourcesToAgentInputs([mockDatasource])).toEqual([]); + + expect( + storedDatasourcesToAgentInputs([ + { + ...mockDatasource, + package: { + name: 'mock-package', + title: 'Mock package', + version: '0.0.0', + }, + }, + ]) + ).toEqual([]); + + expect( + storedDatasourcesToAgentInputs([ + { + ...mockDatasource, + inputs: [{ ...mockInput, enabled: false }], + }, + ]) + ).toEqual([]); + }); + + it('returns agent inputs', () => { + expect(storedDatasourcesToAgentInputs([{ ...mockDatasource, inputs: [mockInput] }])).toEqual([ + { + id: 'some-uuid', + name: 'mock-datasource', + type: 'test-logs', + dataset: { namespace: 'default' }, + use_output: 'default', + streams: [ + { + id: 'test-logs-foo', + dataset: { name: 'foo' }, + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], + }, + { + id: 'test-logs-bar', + dataset: { name: 'bar' }, + }, + ], + }, + ]); + }); + + it('returns agent inputs without disabled streams', () => { + expect( + storedDatasourcesToAgentInputs([ + { + ...mockDatasource, + inputs: [ + { + ...mockInput, + streams: [{ ...mockInput.streams[0] }, { ...mockInput.streams[1], enabled: false }], + }, + ], + }, + ]) + ).toEqual([ + { + id: 'some-uuid', + name: 'mock-datasource', + type: 'test-logs', + dataset: { namespace: 'default' }, + use_output: 'default', + streams: [ + { + id: 'test-logs-foo', + dataset: { name: 'foo' }, + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.ts b/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.ts new file mode 100644 index 00000000000000..d5a752e817b4f2 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Datasource, FullAgentConfigInput, FullAgentConfigInputStream } from '../types'; +import { DEFAULT_OUTPUT } from '../constants'; + +export const storedDatasourcesToAgentInputs = ( + datasources: Datasource[] +): FullAgentConfigInput[] => { + const fullInputs: FullAgentConfigInput[] = []; + + datasources.forEach((datasource) => { + if (!datasource.enabled || !datasource.inputs || !datasource.inputs.length) { + return; + } + datasource.inputs.forEach((input) => { + if (!input.enabled) { + return; + } + + const fullInput: FullAgentConfigInput = { + id: datasource.id || datasource.name, + name: datasource.name, + type: input.type, + dataset: { namespace: datasource.namespace || 'default' }, + use_output: DEFAULT_OUTPUT.name, + ...Object.entries(input.config || {}).reduce((acc, [key, { value }]) => { + acc[key] = value; + return acc; + }, {} as { [k: string]: any }), + streams: input.streams + .filter((stream) => stream.enabled) + .map((stream) => { + const fullStream: FullAgentConfigInputStream = { + id: stream.id, + dataset: { name: stream.dataset }, + ...stream.agent_stream, + ...Object.entries(stream.config || {}).reduce((acc, [key, { value }]) => { + acc[key] = value; + return acc; + }, {} as { [k: string]: any }), + }; + if (stream.processors) { + fullStream.processors = stream.processors; + } + return fullStream; + }), + }; + + if (datasource.package) { + fullInput.package = { + name: datasource.package.name, + version: datasource.package.version, + }; + } + + fullInputs.push(fullInput); + }); + }); + + return fullInputs; +}; diff --git a/x-pack/plugins/ingest_manager/common/services/index.ts b/x-pack/plugins/ingest_manager/common/services/index.ts index c595c9a52f66f8..e53d97972fa2f3 100644 --- a/x-pack/plugins/ingest_manager/common/services/index.ts +++ b/x-pack/plugins/ingest_manager/common/services/index.ts @@ -7,7 +7,7 @@ import * as AgentStatusKueryHelper from './agent_status'; export * from './routes'; export { packageToConfigDatasourceInputs, packageToConfigDatasource } from './package_to_config'; -export { storedDatasourceToAgentDatasource } from './datasource_to_agent_datasource'; +export { storedDatasourcesToAgentInputs } from './datasources_to_agent_inputs'; export { configToYaml } from './config_to_yaml'; export { AgentStatusKueryHelper }; export { decodeCloudId } from './decode_cloud_id'; diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts index 7547f56237eec1..36b3176ffa4151 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts @@ -3,12 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { - Datasource, - DatasourcePackage, - DatasourceInput, - DatasourceInputStream, -} from './datasource'; +import { Datasource, DatasourcePackage, DatasourceInputStream } from './datasource'; import { Output } from './output'; export enum AgentConfigStatus { @@ -35,23 +30,22 @@ export interface AgentConfig extends NewAgentConfig { export type AgentConfigSOAttributes = Omit; -export type FullAgentConfigDatasource = Pick< - Datasource, - 'id' | 'name' | 'namespace' | 'enabled' -> & { - package?: Pick; - use_output: string; - inputs: Array< - Omit & { - streams: Array< - Omit & { - [key: string]: any; - } - >; - } - >; +export type FullAgentConfigInputStream = Pick & { + dataset: { name: string }; + [key: string]: any; }; +export interface FullAgentConfigInput { + id: string; + name: string; + type: string; + dataset: { namespace: string }; + use_output: string; + package?: Pick; + streams: FullAgentConfigInputStream[]; + [key: string]: any; +} + export interface FullAgentConfig { id: string; outputs: { @@ -59,7 +53,7 @@ export interface FullAgentConfig { [key: string]: any; }; }; - datasources: FullAgentConfigDatasource[]; + inputs: FullAgentConfigInput[]; revision?: number; settings?: { monitoring: { diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts index ae4cb4e3fce498..50f275bd59137f 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts @@ -10,5 +10,10 @@ export interface CreateFleetSetupResponse { export interface GetFleetStatusResponse { isReady: boolean; - missing_requirements: Array<'tls_required' | 'api_keys' | 'fleet_admin_user'>; + missing_requirements: Array< + | 'tls_required' + | 'api_keys' + | 'fleet_admin_user' + | 'encrypted_saved_object_encryption_key_required' + >; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx index ffab5866f3b6f3..e9c9ce0c513d25 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx @@ -39,7 +39,9 @@ export const SetupPage: React.FunctionComponent<{ }; const content = - missingRequirements.includes('tls_required') || missingRequirements.includes('api_keys') ? ( + missingRequirements.includes('tls_required') || + missingRequirements.includes('api_keys') || + missingRequirements.includes('encrypted_saved_object_encryption_key_required') ? ( <> @@ -53,12 +55,13 @@ export const SetupPage: React.FunctionComponent<{ - + , diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts index 6dbc8d67caaee6..ece7aef2c247fe 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts @@ -19,7 +19,7 @@ export { settingsRoutesService, appRoutesService, packageToConfigDatasourceInputs, - storedDatasourceToAgentDatasource, + storedDatasourcesToAgentInputs, configToYaml, AgentStatusKueryHelper, } from '../../../../common'; diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index cd44b61974b035..0d53092a0a8ff2 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -67,7 +67,8 @@ export interface IngestManagerSetupDeps { export type IngestManagerStartDeps = object; export interface IngestManagerAppContext { - encryptedSavedObjects: EncryptedSavedObjectsPluginStart; + encryptedSavedObjectsStart: EncryptedSavedObjectsPluginStart; + encryptedSavedObjectsSetup?: EncryptedSavedObjectsPluginSetup; security?: SecurityPluginSetup; config$?: Observable; savedObjects: SavedObjectsServiceStart; @@ -115,6 +116,7 @@ export class IngestManagerPlugin private isProductionMode: boolean; private kibanaVersion: string; private httpSetup: HttpServiceSetup | undefined; + private encryptedSavedObjectsSetup: EncryptedSavedObjectsPluginSetup | undefined; constructor(private readonly initializerContext: PluginInitializerContext) { this.config$ = this.initializerContext.config.create(); @@ -129,6 +131,7 @@ export class IngestManagerPlugin if (deps.security) { this.security = deps.security; } + this.encryptedSavedObjectsSetup = deps.encryptedSavedObjects; this.cloud = deps.cloud; registerSavedObjects(core.savedObjects); @@ -187,12 +190,22 @@ export class IngestManagerPlugin } if (config.fleet.enabled) { - registerAgentRoutes(router); - registerEnrollmentApiKeyRoutes(router); - registerInstallScriptRoutes({ - router, - basePath: core.http.basePath, - }); + const isESOUsingEphemeralEncryptionKey = + deps.encryptedSavedObjects.usingEphemeralEncryptionKey; + if (isESOUsingEphemeralEncryptionKey) { + if (this.logger) { + this.logger.warn( + 'Fleet APIs are disabled due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml.' + ); + } + } else { + registerAgentRoutes(router); + registerEnrollmentApiKeyRoutes(router); + registerInstallScriptRoutes({ + router, + basePath: core.http.basePath, + }); + } } } } @@ -204,7 +217,8 @@ export class IngestManagerPlugin } ) { appContextService.start({ - encryptedSavedObjects: plugins.encryptedSavedObjects, + encryptedSavedObjectsStart: plugins.encryptedSavedObjects, + encryptedSavedObjectsSetup: this.encryptedSavedObjectsSetup, security: this.security, config$: this.config$, savedObjects: core.savedObjects, diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts index 30eb6c0ae8caa8..98083434173908 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts @@ -20,6 +20,8 @@ export const getFleetStatusHandler: RequestHandler = async (context, request, re const isProductionMode = appContextService.getIsProductionMode(); const isCloud = appContextService.getCloud()?.isCloudEnabled ?? false; const isTLSCheckDisabled = appContextService.getConfig()?.fleet?.tlsCheckDisabled ?? false; + const isUsingEphemeralEncryptionKey = appContextService.getEncryptedSavedObjectsSetup() + .usingEphemeralEncryptionKey; const missingRequirements: GetFleetStatusResponse['missing_requirements'] = []; if (!isAdminUserSetup) { @@ -32,6 +34,10 @@ export const getFleetStatusHandler: RequestHandler = async (context, request, re missingRequirements.push('tls_required'); } + if (isUsingEphemeralEncryptionKey) { + missingRequirements.push('encrypted_saved_object_encryption_key_required'); + } + const body: GetFleetStatusResponse = { isReady: missingRequirements.length === 0, missing_requirements: missingRequirements, diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts index 17758f6e3d7f12..c46e648ad088a3 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts @@ -59,7 +59,7 @@ describe('agent config', () => { api_key: undefined, }, }, - datasources: [], + inputs: [], revision: 1, settings: { monitoring: { @@ -88,7 +88,7 @@ describe('agent config', () => { api_key: undefined, }, }, - datasources: [], + inputs: [], revision: 1, settings: { monitoring: { @@ -118,7 +118,7 @@ describe('agent config', () => { api_key: undefined, }, }, - datasources: [], + inputs: [], revision: 1, settings: { monitoring: { diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index 18d5d8dedfb1fa..9e0386de747630 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -20,7 +20,7 @@ import { AgentConfigStatus, ListWithKuery, } from '../types'; -import { DeleteAgentConfigResponse, storedDatasourceToAgentDatasource } from '../../common'; +import { DeleteAgentConfigResponse, storedDatasourcesToAgentInputs } from '../../common'; import { listAgents } from './agents'; import { datasourceService } from './datasource'; import { outputService } from './output'; @@ -375,9 +375,7 @@ class AgentConfigService { {} as FullAgentConfig['outputs'] ), }, - datasources: (config.datasources as Datasource[]) - .filter((datasource) => datasource.enabled) - .map((ds) => storedDatasourceToAgentDatasource(ds)), + inputs: storedDatasourcesToAgentInputs(config.datasources as Datasource[]), revision: config.revision, ...(config.monitoring_enabled && config.monitoring_enabled.length > 0 ? { diff --git a/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts index 0d22529fdb0312..efdcbdb5c36bb9 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts @@ -24,7 +24,7 @@ describe('test agent acks services', () => { const mockSavedObjectsClient = savedObjectsClientMock.create(); const mockStartEncryptedSOPlugin = encryptedSavedObjectsMock.createStart(); appContextService.start(({ - encryptedSavedObjects: mockStartEncryptedSOPlugin, + encryptedSavedObjectsStart: mockStartEncryptedSOPlugin, } as unknown) as IngestManagerAppContext); const [ diff --git a/x-pack/plugins/ingest_manager/server/services/app_context.ts b/x-pack/plugins/ingest_manager/server/services/app_context.ts index 9e6220b6958f17..81a16caa8ce9ea 100644 --- a/x-pack/plugins/ingest_manager/server/services/app_context.ts +++ b/x-pack/plugins/ingest_manager/server/services/app_context.ts @@ -6,7 +6,10 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { first } from 'rxjs/operators'; import { SavedObjectsServiceStart, HttpServiceSetup, Logger } from 'src/core/server'; -import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; +import { + EncryptedSavedObjectsClient, + EncryptedSavedObjectsPluginSetup, +} from '../../../encrypted_saved_objects/server'; import { SecurityPluginSetup } from '../../../security/server'; import { IngestManagerConfigType } from '../../common'; import { IngestManagerAppContext } from '../plugin'; @@ -14,6 +17,7 @@ import { CloudSetup } from '../../../cloud/server'; class AppContextService { private encryptedSavedObjects: EncryptedSavedObjectsClient | undefined; + private encryptedSavedObjectsSetup: EncryptedSavedObjectsPluginSetup | undefined; private security: SecurityPluginSetup | undefined; private config$?: Observable; private configSubject$?: BehaviorSubject; @@ -25,7 +29,8 @@ class AppContextService { private httpSetup?: HttpServiceSetup; public async start(appContext: IngestManagerAppContext) { - this.encryptedSavedObjects = appContext.encryptedSavedObjects?.getClient(); + this.encryptedSavedObjects = appContext.encryptedSavedObjectsStart?.getClient(); + this.encryptedSavedObjectsSetup = appContext.encryptedSavedObjectsSetup; this.security = appContext.security; this.savedObjects = appContext.savedObjects; this.isProductionMode = appContext.isProductionMode; @@ -95,6 +100,14 @@ class AppContextService { return this.httpSetup; } + public getEncryptedSavedObjectsSetup() { + if (!this.encryptedSavedObjectsSetup) { + throw new Error('encryptedSavedObjectsSetup is not set'); + } + + return this.encryptedSavedObjectsSetup; + } + public getKibanaVersion() { if (!this.kibanaVersion) { throw new Error('Kibana version is not set.'); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/__snapshots__/install.test.ts.snap b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/__snapshots__/install.test.ts.snap index 029e278b5aa93c..d8495840453f3d 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/__snapshots__/install.test.ts.snap +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/__snapshots__/install.test.ts.snap @@ -611,7 +611,7 @@ exports[`creating index patterns from yaml fields createIndexPatternFields funct } `; -exports[`creating index patterns from yaml fields flattenFields function flattens recursively and handles copying alias fields: flattenFields 1`] = ` +exports[`creating index patterns from yaml fields flattenFields function flattens recursively and handles copying alias fields flattenFields matches snapshot: flattenFields 1`] = ` [ { "name": "coredns.id", diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts index d083b40e40fd5e..3425a698bd1721 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts @@ -56,9 +56,15 @@ describe('creating index patterns from yaml fields', () => { expect(indexPattern).toMatchSnapshot('createIndexPattern'); }); - test('flattenFields function flattens recursively and handles copying alias fields', () => { - const flattened = flattenFields(fields); - expect(flattened).toMatchSnapshot('flattenFields'); + describe('flattenFields function flattens recursively and handles copying alias fields', () => { + test('a field of type group with no nested fields is skipped', () => { + const flattened = flattenFields([{ name: 'nginx', type: 'group' }]); + expect(flattened.length).toBe(0); + }); + test('flattenFields matches snapshot', () => { + const flattened = flattenFields(fields); + expect(flattened).toMatchSnapshot('flattenFields'); + }); }); describe('dedupFields', () => { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts index 0f7b1d6cab1782..69cd35f3050cdd 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts @@ -307,6 +307,10 @@ export const transformField = (field: Field, i: number, fields: Fields): IndexPa export const flattenFields = (allFields: Fields): Fields => { const flatten = (fields: Fields): Fields => fields.reduce((acc, field) => { + // if this is a group fields with no fields, skip the field + if (field.type === 'group' && !field.fields?.length) { + return acc; + } // recurse through nested fields if (field.type === 'group' && field.fields?.length) { // skip if field.enabled is not explicitly set to false diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.fields.yml b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.fields.yml index 220225a2c246b9..7c2e721d564e7c 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.fields.yml +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.fields.yml @@ -116,3 +116,5 @@ type: keyword - name: text type: text +- name: nginx + type: group \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/types/index.tsx b/x-pack/plugins/ingest_manager/server/types/index.tsx index 2218d967fa8aa1..2b543490ca8dab 100644 --- a/x-pack/plugins/ingest_manager/server/types/index.tsx +++ b/x-pack/plugins/ingest_manager/server/types/index.tsx @@ -20,7 +20,7 @@ export { Datasource, NewDatasource, DatasourceSOAttributes, - FullAgentConfigDatasource, + FullAgentConfigInput, FullAgentConfig, AgentConfig, AgentConfigSOAttributes, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx index a0e7c8fd8bcd73..52d1a77c1df6da 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx @@ -128,8 +128,7 @@ export const PipelineFormFields: React.FunctionComponent = ({ return ( (PipelineProcessorsEditor, { - doMountAsync: false, -}); - -export interface SetupResult extends TestBed { - actions: { - toggleOnFailure: () => void; - }; -} - -export const setup = async (props: Props): Promise => { - const testBed = await testBedSetup(props); - const toggleOnFailure = () => { - const { find } = testBed; - find('pipelineEditorOnFailureToggle').simulate('click'); - }; - - return { - ...testBed, - actions: { toggleOnFailure }, - }; -}; - -type TestSubject = - | 'pipelineEditorDoneButton' - | 'pipelineEditorOnFailureToggle' - | 'pipelineEditorOnFailureTree'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx new file mode 100644 index 00000000000000..97c74e0b2231f0 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { act } from 'react-dom/test-utils'; +import React from 'react'; +import { registerTestBed, TestBed } from '../../../../../../../test_utils'; +import { PipelineProcessorsEditor, Props } from '../pipeline_processors_editor.container'; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + // Mocking EuiComboBox, as it utilizes "react-virtualized" for rendering search suggestions, + // which does not produce a valid component wrapper + EuiComboBox: (props: any) => ( + { + props.onChange([syntheticEvent['0']]); + }} + /> + ), + // Mocking EuiCodeEditor, which uses React Ace under the hood + EuiCodeEditor: (props: any) => ( + { + props.onChange(e.jsonContent); + }} + /> + ), +})); + +jest.mock('react-virtualized', () => ({ + ...jest.requireActual('react-virtualized'), + AutoSizer: ({ children }: { children: any }) => ( +
{children({ height: 500, width: 500 })}
+ ), +})); + +const testBedSetup = registerTestBed(PipelineProcessorsEditor, { + doMountAsync: false, +}); + +export interface SetupResult extends TestBed { + actions: ReturnType; +} + +/** + * We make heavy use of "processorSelector" in these actions. They are a way to uniquely identify + * a processor and are a stringified version of {@link ProcessorSelector}. + * + * @remark + * See also {@link selectorToDataTestSubject}. + */ +const createActions = (testBed: TestBed) => { + const { find, component } = testBed; + + return { + async addProcessor(processorsSelector: string, type: string, options: Record) { + find(`${processorsSelector}.addProcessorButton`).simulate('click'); + await act(async () => { + find('processorTypeSelector').simulate('change', [{ value: type, label: type }]); + }); + component.update(); + await act(async () => { + find('processorOptionsEditor').simulate('change', { + jsonContent: JSON.stringify(options), + }); + }); + await act(async () => { + find('processorSettingsForm.submitButton').simulate('click'); + }); + }, + + removeProcessor(processorSelector: string) { + find(`${processorSelector}.moreMenu.button`).simulate('click'); + find(`${processorSelector}.moreMenu.deleteButton`).simulate('click'); + act(() => { + find('removeProcessorConfirmationModal.confirmModalConfirmButton').simulate('click'); + }); + }, + + moveProcessor(processorSelector: string, dropZoneSelector: string) { + act(() => { + find(`${processorSelector}.moveItemButton`).simulate('click'); + }); + act(() => { + find(dropZoneSelector).last().simulate('click'); + }); + component.update(); + }, + + async addOnFailureProcessor( + processorSelector: string, + type: string, + options: Record + ) { + find(`${processorSelector}.moreMenu.button`).simulate('click'); + find(`${processorSelector}.moreMenu.addOnFailureButton`).simulate('click'); + await act(async () => { + find('processorTypeSelector').simulate('change', [{ value: type, label: type }]); + }); + component.update(); + await act(async () => { + find('processorOptionsEditor').simulate('change', { + jsonContent: JSON.stringify(options), + }); + }); + await act(async () => { + find('processorSettingsForm.submitButton').simulate('click'); + }); + }, + + duplicateProcessor(processorSelector: string) { + find(`${processorSelector}.moreMenu.button`).simulate('click'); + act(() => { + find(`${processorSelector}.moreMenu.duplicateButton`).simulate('click'); + }); + }, + + startAndCancelMove(processorSelector: string) { + act(() => { + find(`${processorSelector}.moveItemButton`).simulate('click'); + }); + component.update(); + act(() => { + find(`${processorSelector}.cancelMoveItemButton`).simulate('click'); + }); + }, + + toggleOnFailure() { + find('pipelineEditorOnFailureToggle').simulate('click'); + }, + }; +}; + +export const setup = async (props: Props): Promise => { + const testBed = await testBedSetup(props); + return { + ...testBed, + actions: createActions(testBed), + }; +}; + +type TestSubject = + | 'pipelineEditorDoneButton' + | 'pipelineEditorOnFailureToggle' + | 'addProcessorsButtonLevel1' + | 'processorSettingsForm' + | 'processorSettingsForm.submitButton' + | 'processorOptionsEditor' + | 'processorSettingsFormFlyout' + | 'processorTypeSelector' + | 'pipelineEditorOnFailureTree' + | string; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx index 758d6f5e620ce4..15121cc71c3215 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx @@ -3,8 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { setup } from './pipeline_processors_editor.helpers'; +import { setup, SetupResult } from './pipeline_processors_editor.helpers'; import { Pipeline } from '../../../../../common/types'; const testProcessors: Pick = { @@ -25,10 +24,20 @@ const testProcessors: Pick = { }; describe('Pipeline Editor', () => { - it('provides the same data out it got in if nothing changes', async () => { - const onUpdate = jest.fn(); + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); - await setup({ + beforeEach(async () => { + onUpdate = jest.fn(); + testBed = await setup({ value: { ...testProcessors, }, @@ -36,10 +45,11 @@ describe('Pipeline Editor', () => { onUpdate, isTestButtonDisabled: false, onTestPipelineClick: jest.fn(), - learnMoreAboutProcessorsUrl: 'test', - learnMoreAboutOnFailureProcessorsUrl: 'test', + esDocsBasePath: 'test', }); + }); + it('provides the same data out it got in if nothing changes', () => { const { calls: [[arg]], } = onUpdate.mock; @@ -47,21 +57,134 @@ describe('Pipeline Editor', () => { expect(arg.getData()).toEqual(testProcessors); }); - it('toggles the on-failure processors', async () => { - const { actions, exists } = await setup({ - value: { - ...testProcessors, - }, - onFlyoutOpen: jest.fn(), - onUpdate: jest.fn(), - isTestButtonDisabled: false, - onTestPipelineClick: jest.fn(), - learnMoreAboutProcessorsUrl: 'test', - learnMoreAboutOnFailureProcessorsUrl: 'test', - }); - + it('toggles the on-failure processors tree', () => { + const { actions, exists } = testBed; expect(exists('pipelineEditorOnFailureTree')).toBe(false); actions.toggleOnFailure(); expect(exists('pipelineEditorOnFailureTree')).toBe(true); }); + + describe('processors', () => { + it('adds a new processor', async () => { + const { actions } = testBed; + await actions.addProcessor('processors', 'test', { if: '1 == 1' }); + const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const { processors } = onUpdateResult.getData(); + expect(processors.length).toBe(3); + const [a, b, c] = processors; + expect(a).toEqual(testProcessors.processors[0]); + expect(b).toEqual(testProcessors.processors[1]); + expect(c).toEqual({ test: { if: '1 == 1' } }); + }); + + it('removes a processor', () => { + const { actions } = testBed; + // processor>0 denotes the first processor in the top-level processors array. + actions.removeProcessor('processors>0'); + const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const { processors } = onUpdateResult.getData(); + expect(processors.length).toBe(1); + expect(processors[0]).toEqual({ + gsub: { + field: '_index', + pattern: '(.monitoring-\\w+-)6(-.+)', + replacement: '$17$2', + }, + }); + }); + + it('reorders processors', () => { + const { actions } = testBed; + actions.moveProcessor('processors>0', 'dropButtonBelow-processors>1'); + const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const { processors } = onUpdateResult.getData(); + expect(processors).toEqual(testProcessors.processors.slice(0).reverse()); + }); + + it('adds an on-failure processor to a processor', async () => { + const { actions, find, exists } = testBed; + const processorSelector = 'processors>1'; + await actions.addOnFailureProcessor(processorSelector, 'test', { if: '1 == 2' }); + // Assert that the add on failure button has been removed + find(`${processorSelector}.moreMenu.button`).simulate('click'); + expect(!exists(`${processorSelector}.moreMenu.addOnFailureButton`)); + // Assert that the add processor button is now visible + expect(exists(`${processorSelector}.addProcessor`)); + const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const { processors } = onUpdateResult.getData(); + expect(processors.length).toBe(2); + expect(processors[0]).toEqual(testProcessors.processors[0]); // should be unchanged + expect(processors[1].gsub).toEqual({ + ...testProcessors.processors[1].gsub, + on_failure: [{ test: { if: '1 == 2' } }], + }); + }); + + it('moves a processor to a nested dropzone', async () => { + const { actions } = testBed; + await actions.addOnFailureProcessor('processors>1', 'test', { if: '1 == 3' }); + actions.moveProcessor('processors>0', 'dropButtonBelow-processors>1>onFailure>0'); + const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const { processors } = onUpdateResult.getData(); + expect(processors.length).toBe(1); + expect(processors[0].gsub.on_failure).toEqual([ + { + test: { if: '1 == 3' }, + }, + testProcessors.processors[0], + ]); + }); + + it('duplicates a processor', async () => { + const { actions } = testBed; + await actions.addOnFailureProcessor('processors>1', 'test', { if: '1 == 4' }); + actions.duplicateProcessor('processors>1'); + const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const { processors } = onUpdateResult.getData(); + expect(processors.length).toBe(3); + const duplicatedProcessor = { + gsub: { + ...testProcessors.processors[1].gsub, + on_failure: [{ test: { if: '1 == 4' } }], + }, + }; + expect(processors).toEqual([ + testProcessors.processors[0], + duplicatedProcessor, + duplicatedProcessor, + ]); + }); + + it('can cancel a move', () => { + const { actions, exists } = testBed; + const processorSelector = 'processors>0'; + actions.startAndCancelMove(processorSelector); + // Assert that we have exited move mode for this processor + expect(exists(`moveItemButton-${processorSelector}`)); + const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const { processors } = onUpdateResult.getData(); + // Assert that nothing has changed + expect(processors).toEqual(testProcessors.processors); + }); + + it('moves to and from the global on-failure tree', async () => { + const { actions } = testBed; + actions.toggleOnFailure(); + await actions.addProcessor('onFailure', 'test', { if: '1 == 5' }); + actions.moveProcessor('processors>0', 'dropButtonBelow-onFailure>0'); + const [onUpdateResult1] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const data1 = onUpdateResult1.getData(); + expect(data1.processors.length).toBe(1); + expect(data1.on_failure.length).toBe(2); + expect(data1.processors).toEqual([testProcessors.processors[1]]); + expect(data1.on_failure).toEqual([{ test: { if: '1 == 5' } }, testProcessors.processors[0]]); + actions.moveProcessor('onFailure>1', 'dropButtonAbove-processors>0'); + const [onUpdateResult2] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const data2 = onUpdateResult2.getData(); + expect(data2.processors.length).toBe(2); + expect(data2.on_failure.length).toBe(1); + expect(data2.processors).toEqual(testProcessors.processors); + expect(data2.on_failure).toEqual([{ test: { if: '1 == 5' } }]); + }); + }); }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx index 5f9bf87ceca1ed..276d684e3dca14 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx @@ -13,12 +13,14 @@ export interface Props { onClick: () => void; } -export const AddProcessorButton: FunctionComponent = ({ onClick }) => { +export const AddProcessorButton: FunctionComponent = (props) => { + const { onClick } = props; const { state: { editor }, } = usePipelineProcessorsContext(); return ( { defaultMessage="The processors used to pre-process documents before indexing. {learnMoreLink}" values={{ learnMoreLink: ( - + {i18n.translate( 'xpack.ingestPipelines.pipelineEditor.onFailureProcessorsDocumentationLink', { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx index bc7d6fdcff357e..64364e921a3a11 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx @@ -16,19 +16,16 @@ interface Props { onDuplicate: () => void; onDelete: () => void; onAddOnFailure: () => void; + 'data-test-subj'?: string; } -export const ContextMenu: FunctionComponent = ({ - showAddOnFailure, - onDuplicate, - onAddOnFailure, - onDelete, - disabled, -}) => { +export const ContextMenu: FunctionComponent = (props) => { + const { showAddOnFailure, onDuplicate, onAddOnFailure, onDelete, disabled } = props; const [isOpen, setIsOpen] = useState(false); const contextMenuItems = [ { @@ -40,6 +37,7 @@ export const ContextMenu: FunctionComponent = ({ , showAddOnFailure ? ( { @@ -51,6 +49,7 @@ export const ContextMenu: FunctionComponent = ({ ) : undefined, = ({ return ( setIsOpen(false)} button={ setIsOpen((v) => !v)} iconType="boxesHorizontal" diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx index 0e47b3ef7cf88e..0eb259db75f47c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx @@ -8,6 +8,7 @@ import React, { FunctionComponent, memo } from 'react'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; import { ProcessorInternal, ProcessorSelector } from '../../types'; +import { selectorToDataTestSubject } from '../../utils'; import { usePipelineProcessorsContext } from '../../context'; @@ -46,6 +47,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( responsive={false} alignItems="center" justifyContent="spaceBetween" + data-test-subj={selectorToDataTestSubject(selector)} > @@ -85,6 +87,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( = memo( {selected ? ( = memo( ) : ( = memo( { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/documentation_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/documentation_button.tsx new file mode 100644 index 00000000000000..b1fd9e97aa23d3 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/documentation_button.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface Props { + processorLabel: string; + docLink: string; +} + +export const DocumentationButton: FunctionComponent = ({ processorLabel, docLink }) => { + return ( + + {i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.settingsForm.learnMoreLabelLink.processor', + { defaultMessage: '{processorLabel} documentation', values: { processorLabel } } + )} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts index 60a1aa0a96fb10..1a7da4891967a2 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts @@ -7,4 +7,5 @@ export { ProcessorSettingsForm, ProcessorSettingsFromOnSubmitArg, + OnSubmitHandler, } from './processor_settings_form.container'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx index e8164a0057d393..5993d7fb3f87a2 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx @@ -4,53 +4,270 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { FunctionComponent } from 'react'; // import { SetProcessor } from './processors/set'; // import { Gsub } from './processors/gsub'; -const mapProcessorTypeToForm = { - append: undefined, // TODO: Implement - bytes: undefined, // TODO: Implement - circle: undefined, // TODO: Implement - convert: undefined, // TODO: Implement - csv: undefined, // TODO: Implement - date: undefined, // TODO: Implement - date_index_name: undefined, // TODO: Implement - dissect: undefined, // TODO: Implement - dot_expander: undefined, // TODO: Implement - drop: undefined, // TODO: Implement - enrich: undefined, // TODO: Implement - fail: undefined, // TODO: Implement - foreach: undefined, // TODO: Implement - geoip: undefined, // TODO: Implement - grok: undefined, // TODO: Implement - html_strip: undefined, // TODO: Implement - inference: undefined, // TODO: Implement - join: undefined, // TODO: Implement - json: undefined, // TODO: Implement - kv: undefined, // TODO: Implement - lowercase: undefined, // TODO: Implement - pipeline: undefined, // TODO: Implement - remove: undefined, // TODO: Implement - rename: undefined, // TODO: Implement - script: undefined, // TODO: Implement - set_security_user: undefined, // TODO: Implement - split: undefined, // TODO: Implement - sort: undefined, // TODO: Implement - trim: undefined, // TODO: Implement - uppercase: undefined, // TODO: Implement - urldecode: undefined, // TODO: Implement - user_agent: undefined, // TODO: Implement - - gsub: undefined, - set: undefined, +interface FieldsFormDescriptor { + FieldsComponent?: FunctionComponent; + docLinkPath: string; + /** + * A sentence case label that can be displayed to users + */ + label: string; +} + +const mapProcessorTypeToFormDescriptor: Record = { + append: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/append-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.append', { + defaultMessage: 'Append', + }), + }, + bytes: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/bytes-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.bytes', { + defaultMessage: 'Bytes', + }), + }, + circle: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/ingest-circle-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.circle', { + defaultMessage: 'Circle', + }), + }, + convert: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/convert-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.convert', { + defaultMessage: 'Convert', + }), + }, + csv: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/csv-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.csv', { + defaultMessage: 'CSV', + }), + }, + date: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/date-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.date', { + defaultMessage: 'Date', + }), + }, + date_index_name: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/date-index-name-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.dateIndexName', { + defaultMessage: 'Date Index Name', + }), + }, + dissect: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/dissect-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.dissect', { + defaultMessage: 'Dissect', + }), + }, + dot_expander: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/dot-expand-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.dotExpander', { + defaultMessage: 'Dot Expander', + }), + }, + drop: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/drop-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.drop', { + defaultMessage: 'Drop', + }), + }, + enrich: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/enrich-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.enrich', { + defaultMessage: 'Enrich', + }), + }, + fail: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/fail-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.fail', { + defaultMessage: 'Fail', + }), + }, + foreach: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/foreach-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.foreach', { + defaultMessage: 'Foreach', + }), + }, + geoip: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/geoip-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.geoip', { + defaultMessage: 'GeoIP', + }), + }, + gsub: { + FieldsComponent: undefined, + docLinkPath: '/gsub-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.gsub', { + defaultMessage: 'Gsub', + }), + }, + html_strip: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/htmlstrip-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.htmlStrip', { + defaultMessage: 'HTML Strip', + }), + }, + inference: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/inference-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.inference', { + defaultMessage: 'Inference', + }), + }, + join: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/join-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.join', { + defaultMessage: 'Join', + }), + }, + json: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/json-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.json', { + defaultMessage: 'JSON', + }), + }, + kv: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/kv-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.kv', { + defaultMessage: 'KV', + }), + }, + lowercase: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/lowercase-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.lowercase', { + defaultMessage: 'Lowercase', + }), + }, + pipeline: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/pipeline-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.pipeline', { + defaultMessage: 'Pipeline', + }), + }, + remove: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/remove-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.remove', { + defaultMessage: 'Remove', + }), + }, + rename: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/rename-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.rename', { + defaultMessage: 'Rename', + }), + }, + script: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/script-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.script', { + defaultMessage: 'Script', + }), + }, + set_security_user: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/ingest-node-set-security-user-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.setSecurityUser', { + defaultMessage: 'Set Security User', + }), + }, + split: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/split-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.split', { + defaultMessage: 'Split', + }), + }, + sort: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/sort-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.sort', { + defaultMessage: 'Sort', + }), + }, + trim: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/trim-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.trim', { + defaultMessage: 'Trim', + }), + }, + uppercase: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/uppercase-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.uppercase', { + defaultMessage: 'Uppercase', + }), + }, + urldecode: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/urldecode-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.urldecode', { + defaultMessage: 'URL Decode', + }), + }, + user_agent: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/user-agent-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.userAgent', { + defaultMessage: 'User Agent', + }), + }, + + // --- The below processor descriptors have components implemented --- + set: { + FieldsComponent: undefined, + docLinkPath: '/set-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.set', { + defaultMessage: 'Set', + }), + }, + grok: { + FieldsComponent: undefined, + docLinkPath: '/grok-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.grok', { + defaultMessage: 'Grok', + }), + }, }; -export const types = Object.keys(mapProcessorTypeToForm); +export const types = Object.keys(mapProcessorTypeToFormDescriptor).sort(); -export type ProcessorType = keyof typeof mapProcessorTypeToForm; +export type ProcessorType = keyof typeof mapProcessorTypeToFormDescriptor; -export const getProcessorForm = (type: ProcessorType | string): FunctionComponent | undefined => { - return mapProcessorTypeToForm[type as ProcessorType]; +export const getProcessorFormDescriptor = ( + type: ProcessorType | string +): FieldsFormDescriptor | undefined => { + return mapProcessorTypeToFormDescriptor[type as ProcessorType]; }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx index 29b52ef84600ab..d76e9225c1a137 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx @@ -13,9 +13,16 @@ import { ProcessorSettingsForm as ViewComponent } from './processor_settings_for export type ProcessorSettingsFromOnSubmitArg = Omit; +export type OnSubmitHandler = (processor: ProcessorSettingsFromOnSubmitArg) => void; + +export type OnFormUpdateHandler = (form: OnFormUpdateArg) => void; + interface Props { - onFormUpdate: (form: OnFormUpdateArg) => void; - onSubmit: (processor: ProcessorSettingsFromOnSubmitArg) => void; + onFormUpdate: OnFormUpdateHandler; + onSubmit: OnSubmitHandler; + isOnFailure: boolean; + onOpen: () => void; + onClose: () => void; processor?: ProcessorInternal; } @@ -23,6 +30,7 @@ export const ProcessorSettingsForm: FunctionComponent = ({ processor, onFormUpdate, onSubmit, + ...rest }) => { const handleSubmit = useCallback( async (data: FormData, isValid: boolean) => { @@ -52,5 +60,5 @@ export const ProcessorSettingsForm: FunctionComponent = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [onFormUpdate]); - return ; + return ; }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx index 49bde2129aab6d..84dfce64f602b7 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx @@ -3,67 +3,141 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent, memo } from 'react'; -import { EuiButton, EuiHorizontalRule } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FunctionComponent, memo, useEffect } from 'react'; +import { + EuiButton, + EuiHorizontalRule, + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { Form, useForm, FormDataProvider } from '../../../../../shared_imports'; - +import { usePipelineProcessorsContext } from '../../context'; import { ProcessorInternal } from '../../types'; -import { getProcessorForm } from './map_processor_type_to_form'; +import { DocumentationButton } from './documentation_button'; +import { ProcessorSettingsFromOnSubmitArg } from './processor_settings_form.container'; +import { getProcessorFormDescriptor } from './map_processor_type_to_form'; import { CommonProcessorFields, ProcessorTypeField } from './processors/common_fields'; import { Custom } from './processors/custom'; +export type OnSubmitHandler = (processor: ProcessorSettingsFromOnSubmitArg) => void; + export interface Props { + isOnFailure: boolean; processor?: ProcessorInternal; form: ReturnType['form']; + onClose: () => void; + onOpen: () => void; } export const ProcessorSettingsForm: FunctionComponent = memo( - ({ processor, form }) => { + ({ processor, form, isOnFailure, onClose, onOpen }) => { + const { + links: { esDocsBasePath }, + } = usePipelineProcessorsContext(); + + const flyoutTitleContent = isOnFailure ? ( + + ) : ( + + ); + + useEffect( + () => { + onOpen(); + }, + [] /* eslint-disable-line react-hooks/exhaustive-deps */ + ); + return ( -
- - - - - - {(arg: any) => { - const { type } = arg; - let formContent: React.ReactNode | undefined; - - if (type?.length) { - const ProcessorFormFields = getProcessorForm(type as any); - - if (ProcessorFormFields) { - formContent = ( - <> - - - - ); - } else { - formContent = ; - } - - return ( - <> - {formContent} - - {i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.settingsForm.submitButtonLabel', - { defaultMessage: 'Submit' } - )} - - - ); - } - - // If the user has not yet defined a type, we do not show any settings fields - return null; - }} - + + + + + +
+ +

{flyoutTitleContent}

+
+
+
+ + + + {({ type }) => { + const formDescriptor = getProcessorFormDescriptor(type as any); + + if (formDescriptor) { + return ( + + ); + } + return null; + }} + + +
+
+ + + + + + + {(arg: any) => { + const { type } = arg; + let formContent: React.ReactNode | undefined; + + if (type?.length) { + const formDescriptor = getProcessorFormDescriptor(type as any); + + if (formDescriptor?.FieldsComponent) { + formContent = ( + <> + + + + ); + } else { + formContent = ; + } + + return ( + <> + {formContent} + + {i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.settingsForm.submitButtonLabel', + { defaultMessage: 'Submit' } + )} + + + ); + } + + // If the user has not yet defined a type, we do not show any settings fields + return null; + }} + + +
); }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/processor_type_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/processor_type_field.tsx index 6c86fc16bcdd06..4b82fbfad9b522 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/processor_type_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/processor_type_field.tsx @@ -54,6 +54,7 @@ export const ProcessorTypeField: FunctionComponent = ({ initialType }) => component={ComboBoxField} componentProps={{ euiFieldProps: { + 'data-test-subj': 'processorTypeSelector', fullWidth: true, options: types.map((type) => ({ label: type, value: type })), noSuggestions: false, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/custom.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/custom.tsx index 61fc31a7b472a7..4d8634e6f28550 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/custom.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/custom.tsx @@ -76,6 +76,7 @@ export const Custom: FunctionComponent = ({ defaultOptions }) => { defaultValue={defaultOptions} componentProps={{ euiCodeEditorProps: { + 'data-test-subj': 'processorOptionsEditor', height: '300px', 'aria-label': i18n.translate( 'xpack.ingestPipelines.pipelineEditor.customForm.optionsFieldAriaLabel', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx index bc646c9eefa559..6d1e2610b5c2b0 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx @@ -42,7 +42,7 @@ export const ProcessorsTitleAndTestButton: FunctionComponent = ({ defaultMessage="The processors used to pre-process documents before indexing. {learnMoreLink}" values={{ learnMoreLink: ( - + {i18n.translate( 'xpack.ingestPipelines.pipelineEditor.processorsDocumentationLink', { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx index a47886292cf321..b64de4f6139115 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx @@ -12,13 +12,15 @@ import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; export interface Props { isDisabled: boolean; onClick: (event: React.MouseEvent) => void; + 'data-test-subj'?: string; } const MOVE_HERE_LABEL = i18n.translate('xpack.ingestPipelines.pipelineEditor.moveTargetLabel', { defaultMessage: 'Move here', }); -export const DropZoneButton: FunctionComponent = ({ onClick, isDisabled }) => { +export const DropZoneButton: FunctionComponent = (props) => { + const { onClick, isDisabled } = props; const containerClasses = classNames({ 'pipelineProcessorsEditor__tree__dropZoneContainer--active': !isDisabled, }); @@ -31,6 +33,7 @@ export const DropZoneButton: FunctionComponent = ({ onClick, isDisabled } className={`pipelineProcessorsEditor__tree__dropZoneContainer ${containerClasses}`} > = ({ info: ProcessorInfo; processor: ProcessorInternal; }) => { + const stringifiedSelector = selectorToDataTestSubject(info.selector); return ( <> {idx === 0 ? ( { event.preventDefault(); onAction({ @@ -102,6 +105,7 @@ export const PrivateTree: FunctionComponent = ({ />
{ event.preventDefault(); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx index ebe4ca4962b4c1..a396a7f4d5ecd5 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx @@ -89,6 +89,7 @@ export const TreeNode: FunctionComponent = ({ processors={processor.onFailure} /> onAction({ type: 'addProcessor', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx index d0661913515b26..db71cf25faaccc 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, keyCodes } from '@elastic/eui'; import { List, WindowScroller } from 'react-virtualized'; import { ProcessorInternal, ProcessorSelector } from '../../types'; +import { selectorToDataTestSubject } from '../../utils'; import './processors_tree.scss'; import { AddProcessorButton } from '../add_processor_button'; @@ -96,7 +97,7 @@ export const ProcessorsTree: FunctionComponent = memo((props) => {
- + { onAction({ type: 'addProcessor', payload: { target: baseSelector } }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx deleted file mode 100644 index 94d5f0eda64542..00000000000000 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; - -import React, { FunctionComponent, memo, useEffect } from 'react'; - -import { FormattedMessage } from '@kbn/i18n/react'; - -import { OnFormUpdateArg } from '../../../../shared_imports'; - -import { ProcessorInternal } from '../types'; - -import { ProcessorSettingsForm, ProcessorSettingsFromOnSubmitArg } from '.'; - -export type OnSubmitHandler = (processor: ProcessorSettingsFromOnSubmitArg) => void; - -export interface Props { - processor: ProcessorInternal | undefined; - onFormUpdate: (form: OnFormUpdateArg) => void; - onSubmit: OnSubmitHandler; - isOnFailureProcessor: boolean; - onOpen: () => void; - onClose: () => void; -} - -export const SettingsFormFlyout: FunctionComponent = memo( - ({ onClose, processor, onSubmit, onFormUpdate, onOpen, isOnFailureProcessor }) => { - useEffect( - () => { - onOpen(); - }, - [] /* eslint-disable-line react-hooks/exhaustive-deps */ - ); - const flyoutTitleContent = isOnFailureProcessor ? ( - - ) : ( - - ); - - return ( - - - -

{flyoutTitleContent}

-
-
- - - -
- ); - } -); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx index 150a52f1a5fe06..fbc06f41208fe1 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx @@ -9,8 +9,7 @@ import { EditorMode } from './types'; import { ProcessorsDispatch } from './processors_reducer'; interface Links { - learnMoreAboutProcessorsUrl: string; - learnMoreAboutOnFailureProcessorsUrl: string; + esDocsBasePath: string; } const PipelineProcessorsContext = createContext<{ diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx index 057f8638700a42..7257677c08fc20 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx @@ -26,8 +26,7 @@ export interface Props { onUpdate: (arg: OnUpdateHandlerArg) => void; isTestButtonDisabled: boolean; onTestPipelineClick: () => void; - learnMoreAboutProcessorsUrl: string; - learnMoreAboutOnFailureProcessorsUrl: string; + esDocsBasePath: string; /** * Give users a way to react to this component opening a flyout */ @@ -41,8 +40,7 @@ export const PipelineProcessorsEditor: FunctionComponent = ({ onFlyoutOpen, onUpdate, isTestButtonDisabled, - learnMoreAboutOnFailureProcessorsUrl, - learnMoreAboutProcessorsUrl, + esDocsBasePath, onTestPipelineClick, }) => { const deserializedResult = useMemo( @@ -61,7 +59,7 @@ export const PipelineProcessorsEditor: FunctionComponent = ({ return ( void; } -const PROCESSOR_STATE_SCOPE: ProcessorSelector = ['processors']; -const ON_FAILURE_STATE_SCOPE: ProcessorSelector = ['onFailure']; - export const PipelineProcessorsEditor: FunctionComponent = memo( function PipelineProcessorsEditor({ processors, @@ -168,7 +168,7 @@ export const PipelineProcessorsEditor: FunctionComponent = memo(
= memo( = memo( ) : undefined}
{editorMode.id === 'editingProcessor' || editorMode.id === 'creatingProcessor' ? ( - 1} + + selector[0] === ON_FAILURE_STATE_SCOPE || selector.length > 2; + export const PARENT_CHILD_NEST_ERROR = 'PARENT_CHILD_NEST_ERROR'; export const duplicateProcessor = (sourceProcessor: ProcessorInternal): ProcessorInternal => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts index 49d24e8dc35c37..43703ed7d97b61 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts @@ -6,6 +6,8 @@ import { ProcessorSelector } from './types'; +export const selectorToDataTestSubject = (selector: ProcessorSelector) => selector.join('>'); + type Path = string[]; /** diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/pipelines_clone.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/pipelines_clone.tsx index 4c857dc9c74f74..6dc7769ac02a92 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/pipelines_clone.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/pipelines_clone.tsx @@ -12,6 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { SectionLoading, useKibana } from '../../../shared_imports'; import { PipelinesCreate } from '../pipelines_create'; +import { attemptToURIDecode } from '../shared'; export interface ParamProps { sourceName: string; @@ -25,8 +26,9 @@ export const PipelinesClone: FunctionComponent> const { sourceName } = props.match.params; const { services } = useKibana(); + const decodedSourceName = attemptToURIDecode(sourceName); const { error, data: pipeline, isLoading, isInitialRequest } = services.api.useLoadPipeline( - decodeURIComponent(sourceName) + decodedSourceName ); useEffect(() => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/pipelines_create.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/pipelines_create.tsx index b495e142561fa7..acca1c4e03f403 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/pipelines_create.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/pipelines_create.tsx @@ -50,7 +50,7 @@ export const PipelinesCreate: React.FunctionComponent { diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/pipelines_edit.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/pipelines_edit.tsx index e1a7e5d6d1a367..e09cf4820771fc 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/pipelines_edit.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/pipelines_edit.tsx @@ -22,6 +22,8 @@ import { Pipeline } from '../../../../common/types'; import { useKibana, SectionLoading } from '../../../shared_imports'; import { PipelineForm } from '../../components'; +import { attemptToURIDecode } from '../shared'; + interface MatchParams { name: string; } @@ -37,7 +39,7 @@ export const PipelinesEdit: React.FunctionComponent(false); const [saveError, setSaveError] = useState(null); - const decodedPipelineName = decodeURI(decodeURIComponent(name)); + const decodedPipelineName = attemptToURIDecode(name); const { error, data: pipeline, isLoading } = services.api.useLoadPipeline(decodedPipelineName); @@ -54,7 +56,7 @@ export const PipelinesEdit: React.FunctionComponent { diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx index 0803b419bdbe49..a4b67ca80f718e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx @@ -111,7 +111,10 @@ export const PipelineTable: FunctionComponent = ({ render: (name: string) => ( {name} diff --git a/x-pack/legacy/plugins/beats_management/server/utils/polyfills.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/shared/attempt_to_uri_decode.ts similarity index 50% rename from x-pack/legacy/plugins/beats_management/server/utils/polyfills.ts rename to x-pack/plugins/ingest_pipelines/public/application/sections/shared/attempt_to_uri_decode.ts index 5291e2c72be7d9..fe5a0d7932cbbf 100644 --- a/x-pack/legacy/plugins/beats_management/server/utils/polyfills.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/shared/attempt_to_uri_decode.ts @@ -4,14 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -export const entries = (obj: any) => { - const ownProps = Object.keys(obj); - let i = ownProps.length; - const resArray = new Array(i); // preallocate the Array - - while (i--) { - resArray[i] = [ownProps[i], obj[ownProps[i]]]; +export const attemptToURIDecode = (value: string) => { + let result: string; + try { + result = decodeURI(decodeURIComponent(value)); + } catch (e) { + result = value; } - - return resArray; + return result; }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/shared/index.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/shared/index.ts new file mode 100644 index 00000000000000..9326d13851387b --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/shared/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { attemptToURIDecode } from './attempt_to_uri_decode'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts b/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts index 05fdc4b1dfb84c..7f6a87a46fea35 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts @@ -15,6 +15,10 @@ export class DocumentationService { this.esDocBasePath = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; } + public getEsDocsBasePath() { + return this.esDocBasePath; + } + public getIngestNodeUrl() { return `${this.esDocBasePath}/ingest.html`; } diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 0df0aeff593db2..24cfe440bd7d89 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -3,6 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { EntriesArray } from './schemas/types'; + export const DATE_NOW = '2020-04-20T15:25:31.830Z'; export const USER = 'some user'; export const LIST_INDEX = '.lists'; @@ -32,9 +34,25 @@ export const NAMESPACE_TYPE = 'single'; // Exception List specific export const ID = 'uuid_here'; +export const ITEM_ID = 'some-list-item-id'; export const ENDPOINT_TYPE = 'endpoint'; -export const ENTRIES = [ - { field: 'some.field', match: 'some value', match_any: undefined, operator: 'included' }, +export const FIELD = 'host.name'; +export const OPERATOR = 'included'; +export const ENTRY_VALUE = 'some host name'; +export const MATCH = 'match'; +export const MATCH_ANY = 'match_any'; +export const LIST = 'list'; +export const EXISTS = 'exists'; +export const NESTED = 'nested'; +export const ENTRIES: EntriesArray = [ + { + entries: [ + { field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' }, + ], + field: 'some.field', + type: 'nested', + }, + { field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' }, ]; export const ITEM_TYPE = 'simple'; export const _TAGS = []; diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.test.ts b/x-pack/plugins/lists/common/schemas/common/schemas.test.ts new file mode 100644 index 00000000000000..eed5be39b7a03a --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/common/schemas.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { operator_type as operatorType } from './schemas'; + +describe('Common schemas', () => { + describe('operatorType', () => { + test('it should validate for "match"', () => { + const payload = 'match'; + const decoded = operatorType.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate for "match_any"', () => { + const payload = 'match_any'; + const decoded = operatorType.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate for "list"', () => { + const payload = 'list'; + const decoded = operatorType.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate for "exists"', () => { + const payload = 'exists'; + const decoded = operatorType.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should contain 4 keys', () => { + // Might seem like a weird test, but its meant to + // ensure that if operatorType is updated, you + // also update the OperatorTypeEnum, a workaround + // for io-ts not yet supporting enums + // https://github.com/gcanti/io-ts/issues/67 + const keys = Object.keys(operatorType.keys); + + expect(keys.length).toEqual(4); + }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index 3d1f537a9ca41b..fea8a219bc774a 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -127,3 +127,21 @@ export type CursorOrUndefined = t.TypeOf; export const namespace_type = DefaultNamespace; export type NamespaceType = t.TypeOf; + +export const operator = t.keyof({ excluded: null, included: null }); +export type Operator = t.TypeOf; + +export const operator_type = t.keyof({ + exists: null, + list: null, + match: null, + match_any: null, +}); +export type OperatorType = t.TypeOf; +export enum OperatorTypeEnum { + NESTED = 'nested', + MATCH = 'match', + MATCH_ANY = 'match_any', + EXISTS = 'exists', + LIST = 'list', +} diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts index 61437b1f04ce38..ccafe70406ecb7 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts @@ -15,16 +15,15 @@ import { } from './create_exception_list_item_schema'; import { getCreateExceptionListItemSchemaMock } from './create_exception_list_item_schema.mock'; -describe('create_exception_list_schema', () => { - test('it should validate a typical exception list item request', () => { +describe('create_exception_list_item_schema', () => { + test('it should validate a typical exception list item request not counting the auto generated uuid', () => { const payload = getCreateExceptionListItemSchemaMock(); - const outputPayload = getCreateExceptionListItemSchemaMock(); const decoded = createExceptionListItemSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - outputPayload.item_id = (message.schema as CreateExceptionListItemSchema).item_id; + delete (message.schema as CreateExceptionListItemSchema).item_id; expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(outputPayload); + expect(message.schema).toEqual(payload); }); test('it should not accept an undefined for "description"', () => { @@ -75,20 +74,20 @@ describe('create_exception_list_schema', () => { expect(message.schema).toEqual({}); }); - test('it should accept an undefined for "meta" but strip it out', () => { + test('it should accept an undefined for "meta" but strip it out and generate a correct body not counting the auto generated uuid', () => { const payload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete payload.meta; + delete outputPayload.meta; const decoded = createExceptionListItemSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - delete outputPayload.meta; - outputPayload.item_id = (message.schema as CreateExceptionListItemSchema).item_id; + delete (message.schema as CreateExceptionListItemSchema).item_id; expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "comments" but return an array', () => { + test('it should accept an undefined for "comments" but return an array and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.comments; @@ -96,7 +95,7 @@ describe('create_exception_list_schema', () => { const decoded = createExceptionListItemSchema.decode(inputPayload); const checked = exactCheck(inputPayload, decoded); const message = pipe(checked, foldLeftRight); - outputPayload.item_id = (message.schema as CreateExceptionListItemSchema).item_id; + delete (message.schema as CreateExceptionListItemSchema).item_id; expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(outputPayload); }); @@ -109,12 +108,12 @@ describe('create_exception_list_schema', () => { const decoded = createExceptionListItemSchema.decode(inputPayload); const checked = exactCheck(inputPayload, decoded); const message = pipe(checked, foldLeftRight); - outputPayload.item_id = (message.schema as CreateExceptionListItemSchema).item_id; + delete (message.schema as CreateExceptionListItemSchema).item_id; expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "namespace_type" but return enum "single"', () => { + test('it should accept an undefined for "namespace_type" but return enum "single" and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.namespace_type; @@ -122,12 +121,12 @@ describe('create_exception_list_schema', () => { const decoded = createExceptionListItemSchema.decode(inputPayload); const checked = exactCheck(inputPayload, decoded); const message = pipe(checked, foldLeftRight); - outputPayload.item_id = (message.schema as CreateExceptionListItemSchema).item_id; + delete (message.schema as CreateExceptionListItemSchema).item_id; expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "tags" but return an array', () => { + test('it should accept an undefined for "tags" but return an array and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.tags; @@ -135,12 +134,12 @@ describe('create_exception_list_schema', () => { const decoded = createExceptionListItemSchema.decode(inputPayload); const checked = exactCheck(inputPayload, decoded); const message = pipe(checked, foldLeftRight); - outputPayload.item_id = (message.schema as CreateExceptionListItemSchema).item_id; + delete (message.schema as CreateExceptionListItemSchema).item_id; expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "_tags" but return an array', () => { + test('it should accept an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload._tags; @@ -148,7 +147,7 @@ describe('create_exception_list_schema', () => { const decoded = createExceptionListItemSchema.decode(inputPayload); const checked = exactCheck(inputPayload, decoded); const message = pipe(checked, foldLeftRight); - outputPayload.item_id = (message.schema as CreateExceptionListItemSchema).item_id; + delete (message.schema as CreateExceptionListItemSchema).item_id; expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(outputPayload); }); diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts index f0b98cb96f7437..22a56f7d42b707 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts @@ -4,17 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DESCRIPTION, LIST_ID, META, NAME, NAMESPACE_TYPE, TYPE } from '../../constants.mock'; +import { DESCRIPTION, ENDPOINT_TYPE, META, NAME, NAMESPACE_TYPE } from '../../constants.mock'; import { CreateExceptionListSchema } from './create_exception_list_schema'; export const getCreateExceptionListSchemaMock = (): CreateExceptionListSchema => ({ _tags: [], description: DESCRIPTION, - list_id: LIST_ID, + list_id: undefined, meta: META, name: NAME, namespace_type: NAMESPACE_TYPE, tags: [], - type: TYPE, + type: ENDPOINT_TYPE, }); diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.test.ts index f19c50c6b42aed..21270f526900b4 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.test.ts @@ -16,27 +16,28 @@ import { import { getCreateExceptionListSchemaMock } from './create_exception_list_schema.mock'; describe('create_exception_list_schema', () => { - test('it should validate a typical exception lists request', () => { + test('it should validate a typical exception lists request and generate a correct body not counting the uuid', () => { const payload = getCreateExceptionListSchemaMock(); const decoded = createExceptionListSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - + delete (message.schema as CreateExceptionListSchema).list_id; expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should accept an undefined for meta', () => { + test('it should accept an undefined for "meta" and generate a correct body not counting the uuid', () => { const payload = getCreateExceptionListSchemaMock(); delete payload.meta; const decoded = createExceptionListSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); + delete (message.schema as CreateExceptionListSchema).list_id; expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should accept an undefined for tags but return an array', () => { + test('it should accept an undefined for "tags" but return an array and generate a correct body not counting the uuid', () => { const inputPayload = getCreateExceptionListSchemaMock(); const outputPayload = getCreateExceptionListSchemaMock(); delete inputPayload.tags; @@ -44,11 +45,12 @@ describe('create_exception_list_schema', () => { const decoded = createExceptionListSchema.decode(inputPayload); const checked = exactCheck(inputPayload, decoded); const message = pipe(checked, foldLeftRight); + delete (message.schema as CreateExceptionListSchema).list_id; expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for _tags but return an array', () => { + test('it should accept an undefined for "_tags" but return an array and generate a correct body not counting the uuid', () => { const inputPayload = getCreateExceptionListSchemaMock(); const outputPayload = getCreateExceptionListSchemaMock(); delete inputPayload._tags; @@ -56,11 +58,12 @@ describe('create_exception_list_schema', () => { const decoded = createExceptionListSchema.decode(inputPayload); const checked = exactCheck(inputPayload, decoded); const message = pipe(checked, foldLeftRight); + delete (message.schema as CreateExceptionListSchema).list_id; expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for list_id and auto generate a uuid', () => { + test('it should accept an undefined for "list_id" and auto generate a uuid', () => { const inputPayload = getCreateExceptionListSchemaMock(); delete inputPayload.list_id; const decoded = createExceptionListSchema.decode(inputPayload); @@ -72,7 +75,7 @@ describe('create_exception_list_schema', () => { ); }); - test('it should accept an undefined for list_id and generate a correct body not counting the uuid', () => { + test('it should accept an undefined for "list_id" and generate a correct body not counting the uuid', () => { const inputPayload = getCreateExceptionListSchemaMock(); delete inputPayload.list_id; const decoded = createExceptionListSchema.decode(inputPayload); diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.mock.ts new file mode 100644 index 00000000000000..d2d13f58af6898 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.mock.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ID, NAMESPACE_TYPE } from '../../constants.mock'; + +import { DeleteExceptionListItemSchema } from './delete_exception_list_item_schema'; + +export const getDeleteExceptionListItemSchemaMock = (): DeleteExceptionListItemSchema => ({ + id: ID, + namespace_type: NAMESPACE_TYPE, +}); diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.test.ts new file mode 100644 index 00000000000000..4b42f79a893295 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { + DeleteExceptionListItemSchema, + deleteExceptionListItemSchema, +} from './delete_exception_list_item_schema'; +import { getDeleteExceptionListItemSchemaMock } from './delete_exception_list_item_schema.mock'; + +describe('delete_exception_list_item_schema', () => { + test('it should validate a typical exception list item request', () => { + const payload = getDeleteExceptionListItemSchemaMock(); + const decoded = deleteExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + // TODO It does allow an id of undefined, is this wanted behavior? + test.skip('it should NOT accept an undefined for an "id"', () => { + const payload = getDeleteExceptionListItemSchemaMock(); + delete payload.id; + const decoded = deleteExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "undefined" supplied to "id"']); + expect(message.schema).toEqual({}); + }); + + test('it should accept an undefined for "namespace_type" but default to "single"', () => { + const payload = getDeleteExceptionListItemSchemaMock(); + delete payload.namespace_type; + const decoded = deleteExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getDeleteExceptionListItemSchemaMock()); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: DeleteExceptionListItemSchema & { + extraKey?: string; + } = getDeleteExceptionListItemSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = deleteExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.mock.ts new file mode 100644 index 00000000000000..6a8fefdce0925a --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.mock.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ID, NAMESPACE_TYPE } from '../../constants.mock'; + +import { DeleteExceptionListSchema } from './delete_exception_list_schema'; + +export const getDeleteExceptionListSchemaMock = (): DeleteExceptionListSchema => ({ + id: ID, + namespace_type: NAMESPACE_TYPE, +}); diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.test.ts new file mode 100644 index 00000000000000..d925de8013c138 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { + DeleteExceptionListSchema, + deleteExceptionListSchema, +} from './delete_exception_list_schema'; +import { getDeleteExceptionListSchemaMock } from './delete_exception_list_schema.mock'; + +describe('delete_exception_list_schema', () => { + test('it should validate a typical exception list request', () => { + const payload = getDeleteExceptionListSchemaMock(); + const decoded = deleteExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + // TODO It does allow an id of undefined, is this wanted behavior? + test.skip('it should NOT accept an undefined for an id', () => { + const payload = getDeleteExceptionListSchemaMock(); + delete payload.id; + const decoded = deleteExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "undefined" supplied to "id"']); + expect(message.schema).toEqual({}); + }); + + test('it should accept an undefined for "namespace_type" but default to "single"', () => { + const payload = getDeleteExceptionListSchemaMock(); + delete payload.namespace_type; + const decoded = deleteExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getDeleteExceptionListSchemaMock()); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: DeleteExceptionListSchema & { + extraKey?: string; + } = getDeleteExceptionListSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = deleteExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.mock.ts new file mode 100644 index 00000000000000..71c8a4d8c81615 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.mock.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ID, ITEM_ID, NAMESPACE_TYPE } from '../../constants.mock'; + +import { ReadExceptionListItemSchema } from './read_exception_list_item_schema'; + +export const getReadExceptionListItemSchemaMock = (): ReadExceptionListItemSchema => ({ + id: ID, + item_id: ITEM_ID, + namespace_type: NAMESPACE_TYPE, +}); diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.test.ts new file mode 100644 index 00000000000000..86c80a527be0d0 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getReadExceptionListItemSchemaMock } from './read_exception_list_item_schema.mock'; +import { + ReadExceptionListItemSchema, + readExceptionListItemSchema, +} from './read_exception_list_item_schema'; + +describe('read_exception_list_item_schema', () => { + test('it should validate a typical exception list request', () => { + const payload = getReadExceptionListItemSchemaMock(); + const decoded = readExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should accept an undefined for "id"', () => { + const payload = getReadExceptionListItemSchemaMock(); + delete payload.id; + const decoded = readExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should accept an undefined for "item_id"', () => { + const payload = getReadExceptionListItemSchemaMock(); + delete payload.item_id; + const decoded = readExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should accept an undefined for "namespace_type" but default to "single"', () => { + const payload = getReadExceptionListItemSchemaMock(); + delete payload.namespace_type; + const decoded = readExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getReadExceptionListItemSchemaMock()); + }); + + test('it should accept an undefined for "id", "item_id", "namespace_type" but default "namespace_type" to "single"', () => { + const payload = getReadExceptionListItemSchemaMock(); + delete payload.id; + delete payload.namespace_type; + delete payload.item_id; + const output = getReadExceptionListItemSchemaMock(); + delete output.id; + delete output.item_id; + const decoded = readExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(output); + }); + + test('it should accept an undefined for "id", "item_id"', () => { + const payload = getReadExceptionListItemSchemaMock(); + delete payload.id; + delete payload.item_id; + const decoded = readExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should accept an undefined for "id", "namespace_type" but default "namespace_type" to "single"', () => { + const payload = getReadExceptionListItemSchemaMock(); + delete payload.id; + delete payload.namespace_type; + const output = getReadExceptionListItemSchemaMock(); + delete output.id; + const decoded = readExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(output); + }); + + test('it should accept an undefined for "item_id", "namespace_type" but default "namespace_type" to "single"', () => { + const payload = getReadExceptionListItemSchemaMock(); + delete payload.namespace_type; + delete payload.item_id; + const output = getReadExceptionListItemSchemaMock(); + delete output.item_id; + const decoded = readExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(output); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: ReadExceptionListItemSchema & { + extraKey?: string; + } = getReadExceptionListItemSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = readExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts index fded35dfd1cc9c..93a372ba383b0a 100644 --- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts @@ -11,11 +11,13 @@ import * as t from 'io-ts'; import { NamespaceType, id, item_id, namespace_type } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; -export const readExceptionListItemSchema = t.partial({ - id, - item_id, - namespace_type, // defaults to 'single' if not set during decode -}); +export const readExceptionListItemSchema = t.exact( + t.partial({ + id, + item_id, + namespace_type, // defaults to 'single' if not set during decode + }) +); export type ReadExceptionListItemSchemaPartial = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.mock.ts new file mode 100644 index 00000000000000..863ccdc2fb0857 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.mock.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ID, LIST_ID, NAMESPACE_TYPE } from '../../constants.mock'; + +import { ReadExceptionListSchema } from './read_exception_list_schema'; + +export const getReadExceptionListSchemaMock = (): ReadExceptionListSchema => ({ + id: ID, + list_id: LIST_ID, + namespace_type: NAMESPACE_TYPE, +}); diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.test.ts new file mode 100644 index 00000000000000..86cebc3cd3f8eb --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getReadExceptionListSchemaMock } from './read_exception_list_schema.mock'; +import { ReadExceptionListSchema, readExceptionListSchema } from './read_exception_list_schema'; + +describe('read_exception_list_schema', () => { + test('it should validate a typical exception list request', () => { + const payload = getReadExceptionListSchemaMock(); + const decoded = readExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should accept an undefined for "id"', () => { + const payload = getReadExceptionListSchemaMock(); + delete payload.id; + const decoded = readExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should accept an undefined for "list_id"', () => { + const payload = getReadExceptionListSchemaMock(); + delete payload.list_id; + const decoded = readExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should accept an undefined for "namespace_type" but default to "single"', () => { + const payload = getReadExceptionListSchemaMock(); + delete payload.namespace_type; + const decoded = readExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getReadExceptionListSchemaMock()); + }); + + test('it should accept an undefined for "id", "list_id", "namespace_type" but default "namespace_type" to "single"', () => { + const payload = getReadExceptionListSchemaMock(); + delete payload.id; + delete payload.namespace_type; + delete payload.list_id; + const output = getReadExceptionListSchemaMock(); + delete output.id; + delete output.list_id; + const decoded = readExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(output); + }); + + test('it should accept an undefined for "id", "list_id"', () => { + const payload = getReadExceptionListSchemaMock(); + delete payload.id; + delete payload.list_id; + const decoded = readExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should accept an undefined for "id", "namespace_type" but default "namespace_type" to "single"', () => { + const payload = getReadExceptionListSchemaMock(); + delete payload.id; + delete payload.namespace_type; + const output = getReadExceptionListSchemaMock(); + delete output.id; + const decoded = readExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(output); + }); + + test('it should accept an undefined for "list_id", "namespace_type" but default "namespace_type" to "single"', () => { + const payload = getReadExceptionListSchemaMock(); + delete payload.namespace_type; + delete payload.list_id; + const output = getReadExceptionListSchemaMock(); + delete output.list_id; + const decoded = readExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(output); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: ReadExceptionListSchema & { + extraKey?: string; + } = getReadExceptionListSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = readExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts index 6b623ea8c0b9b0..3947c88bf4c9ce 100644 --- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts @@ -11,11 +11,13 @@ import * as t from 'io-ts'; import { NamespaceType, id, list_id, namespace_type } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; -export const readExceptionListSchema = t.partial({ - id, - list_id, - namespace_type, // defaults to 'single' if not set during decode -}); +export const readExceptionListSchema = t.exact( + t.partial({ + id, + list_id, + namespace_type, // defaults to 'single' if not set during decode + }) +); export type ReadExceptionListSchemaPartial = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.mock.ts new file mode 100644 index 00000000000000..48a8baf9aea165 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DESCRIPTION, ID, LIST_ID, META, NAME, NAMESPACE_TYPE, _TAGS } from '../../constants.mock'; + +import { UpdateExceptionListSchema } from './update_exception_list_schema'; + +export const getUpdateExceptionListSchemaMock = (): UpdateExceptionListSchema => ({ + _tags: _TAGS, + description: DESCRIPTION, + id: ID, + list_id: LIST_ID, + meta: META, + name: NAME, + namespace_type: NAMESPACE_TYPE, + tags: ['malware'], + type: 'endpoint', +}); diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.test.ts new file mode 100644 index 00000000000000..71cb9786b81855 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.test.ts @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { + UpdateExceptionListSchema, + updateExceptionListSchema, +} from './update_exception_list_schema'; +import { getUpdateExceptionListSchemaMock } from './update_exception_list_schema.mock'; + +describe('update_exception_list_schema', () => { + test('it should validate a typical exception list request', () => { + const payload = getUpdateExceptionListSchemaMock(); + const decoded = updateExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not accept an undefined for "description"', () => { + const payload = getUpdateExceptionListSchemaMock(); + delete payload.description; + const decoded = updateExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "description"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not accept an undefined for "name"', () => { + const payload = getUpdateExceptionListSchemaMock(); + delete payload.name; + const decoded = updateExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "name"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not accept an undefined for "type"', () => { + const payload = getUpdateExceptionListSchemaMock(); + delete payload.type; + const decoded = updateExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should accept an undefined for "meta" but strip it out', () => { + const payload = getUpdateExceptionListSchemaMock(); + const outputPayload = getUpdateExceptionListSchemaMock(); + delete payload.meta; + const decoded = updateExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + delete outputPayload.meta; + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(outputPayload); + }); + + test('it should accept an undefined for "namespace_type" but return enum "single"', () => { + const inputPayload = getUpdateExceptionListSchemaMock(); + const outputPayload = getUpdateExceptionListSchemaMock(); + delete inputPayload.namespace_type; + outputPayload.namespace_type = 'single'; + const decoded = updateExceptionListSchema.decode(inputPayload); + const checked = exactCheck(inputPayload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(outputPayload); + }); + + test('it should accept an undefined for "tags" but return an array', () => { + const inputPayload = getUpdateExceptionListSchemaMock(); + const outputPayload = getUpdateExceptionListSchemaMock(); + delete inputPayload.tags; + outputPayload.tags = []; + const decoded = updateExceptionListSchema.decode(inputPayload); + const checked = exactCheck(inputPayload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(outputPayload); + }); + + test('it should accept an undefined for "_tags" but return an array', () => { + const inputPayload = getUpdateExceptionListSchemaMock(); + const outputPayload = getUpdateExceptionListSchemaMock(); + delete inputPayload._tags; + outputPayload._tags = []; + const decoded = updateExceptionListSchema.decode(inputPayload); + const checked = exactCheck(inputPayload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(outputPayload); + }); + + // TODO: Is it expected behavior for it not to auto-generate a uui or throw + // error if list_id is not passed in? + test.skip('it should accept an undefined for "list_id" and auto generate a uuid', () => { + const inputPayload = getUpdateExceptionListSchemaMock(); + delete inputPayload.list_id; + const decoded = updateExceptionListSchema.decode(inputPayload); + const checked = exactCheck(inputPayload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect((message.schema as UpdateExceptionListSchema).list_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); + + test('it should accept an undefined for "list_id" and generate a correct body not counting the uuid', () => { + const inputPayload = getUpdateExceptionListSchemaMock(); + delete inputPayload.list_id; + const decoded = updateExceptionListSchema.decode(inputPayload); + const checked = exactCheck(inputPayload, decoded); + const message = pipe(checked, foldLeftRight); + delete (message.schema as UpdateExceptionListSchema).list_id; + expect(message.schema).toEqual(inputPayload); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: UpdateExceptionListSchema & { + extraKey?: string; + } = getUpdateExceptionListSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = updateExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts index 663bfc7038330f..f7a6af98c8f0e9 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { ENTRIES } from '../../constants.mock'; import { ExceptionListItemSchema } from './exception_list_item_schema'; @@ -12,20 +13,7 @@ export const getExceptionListItemSchemaMock = (): ExceptionListItemSchema => ({ created_at: '2020-04-23T00:19:13.289Z', created_by: 'user_name', description: 'This is a sample endpoint type exception', - entries: [ - { - field: 'actingProcess.file.signer', - match: 'Elastic, N.V.', - match_any: undefined, - operator: 'included', - }, - { - field: 'event.category', - match: undefined, - match_any: ['process', 'malware'], - operator: 'included', - }, - ], + entries: ENTRIES, id: '1', item_id: 'endpoint_list_item', list_id: 'endpoint_list', diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.test.ts new file mode 100644 index 00000000000000..99b3b778388eac --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.test.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getExceptionListSchemaMock } from './exception_list_schema.mock'; +import { ExceptionListSchema, exceptionListSchema } from './exception_list_schema'; + +describe('exception_list_schema', () => { + test('it should validate a typical exception list response', () => { + const payload = getExceptionListSchemaMock(); + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT accept an undefined for "id"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.id; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "undefined" supplied to "id"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "list_id"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.list_id; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "list_id"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "name"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.name; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "name"', + ]); + expect(message.schema).toEqual({}); + }); + + // TODO: Should this throw an error? "namespace_type" gets auto-populated + // with default "single", is that desired behavior? + test.skip('it should NOT accept an undefined for "namespace_type"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.namespace_type; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "namespace_type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "description"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.description; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "description"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should accept an undefined for "meta"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.meta; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT accept an undefined for "created_at"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.created_at; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "created_at"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "created_by"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.created_by; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "created_by"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "tie_breaker_id"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.tie_breaker_id; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "tie_breaker_id"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "type"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.type; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "updated_at"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.updated_at; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "updated_at"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "updated_by"', () => { + const payload = getExceptionListSchemaMock(); + delete payload.updated_by; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "updated_by"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: ExceptionListSchema & { + extraKey?: string; + } = getExceptionListSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = exceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/response/found_exception_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/response/found_exception_list_item_schema.test.ts new file mode 100644 index 00000000000000..70fcf9a86122cc --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/found_exception_list_item_schema.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getExceptionListItemSchemaMock } from './exception_list_item_schema.mock'; +import { getFoundExceptionListItemSchemaMock } from './found_exception_list_item_schema.mock'; +import { + FoundExceptionListItemSchema, + foundExceptionListItemSchema, +} from './found_exception_list_item_schema'; +import { ExceptionListItemSchema } from './exception_list_item_schema'; + +describe('found_exception_list_item_schema', () => { + test('it should validate a typical exception list response', () => { + const payload = getFoundExceptionListItemSchemaMock(); + const decoded = foundExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT accept a malformed exception list item within "data"', () => { + const item: Omit & { + entries?: string; + } = { ...getExceptionListItemSchemaMock(), entries: 'I should be an array' }; + const payload: Omit & { + data?: Array< + Omit & { + entries?: string; + } + >; + } = { ...getFoundExceptionListItemSchemaMock(), data: [item] }; + const decoded = foundExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "I should be an array" supplied to "data,entries"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept a string for "page"', () => { + const payload: Omit & { + page?: string; + } = { ...getFoundExceptionListItemSchemaMock(), page: '1' }; + const decoded = foundExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "1" supplied to "page"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept a string for "per_page"', () => { + const payload: Omit & { + per_page?: string; + } = { ...getFoundExceptionListItemSchemaMock(), per_page: '20' }; + const decoded = foundExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "20" supplied to "per_page"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept a string for "total"', () => { + const payload: Omit & { + total?: string; + } = { ...getFoundExceptionListItemSchemaMock(), total: '1' }; + const decoded = foundExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "1" supplied to "total"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "page"', () => { + const payload = getFoundExceptionListItemSchemaMock(); + delete payload.page; + const decoded = foundExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "page"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "per_page"', () => { + const payload = getFoundExceptionListItemSchemaMock(); + delete payload.per_page; + const decoded = foundExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "per_page"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "total"', () => { + const payload = getFoundExceptionListItemSchemaMock(); + delete payload.total; + const decoded = foundExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "total"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "data"', () => { + const payload = getFoundExceptionListItemSchemaMock(); + delete payload.data; + const decoded = foundExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "data"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: FoundExceptionListItemSchema & { + extraKey?: string; + } = getFoundExceptionListItemSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = foundExceptionListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/response/found_exception_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/response/found_exception_list_schema.test.ts new file mode 100644 index 00000000000000..a96ee07c4613b1 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/found_exception_list_schema.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getExceptionListSchemaMock } from './exception_list_schema.mock'; +import { getFoundExceptionListSchemaMock } from './found_exception_list_schema.mock'; +import { FoundExceptionListSchema, foundExceptionListSchema } from './found_exception_list_schema'; +import { ExceptionListSchema } from './exception_list_schema'; + +describe('exception_list_schema', () => { + test('it should validate a typical exception list response', () => { + const payload = getFoundExceptionListSchemaMock(); + const decoded = foundExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT accept a malformed exception list item within "data"', () => { + const item: Omit & { + entries?: string[]; + } = { ...getExceptionListSchemaMock(), entries: ['I should not be here'] }; + const payload: Omit & { + data?: Array< + Omit & { + entries?: string[]; + } + >; + } = { ...getFoundExceptionListSchemaMock(), data: [item] }; + const decoded = foundExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'invalid keys "entries,["I should not be here"]"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept a string for "page"', () => { + const payload: Omit & { + page?: string; + } = { ...getFoundExceptionListSchemaMock(), page: '1' }; + const decoded = foundExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "1" supplied to "page"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept a string for "per_page"', () => { + const payload: Omit & { + per_page?: string; + } = { ...getFoundExceptionListSchemaMock(), per_page: '20' }; + const decoded = foundExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "20" supplied to "per_page"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept a string for "total"', () => { + const payload: Omit & { + total?: string; + } = { ...getFoundExceptionListSchemaMock(), total: '1' }; + const decoded = foundExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "1" supplied to "total"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "page"', () => { + const payload = getFoundExceptionListSchemaMock(); + delete payload.page; + const decoded = foundExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "page"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "per_page"', () => { + const payload = getFoundExceptionListSchemaMock(); + delete payload.per_page; + const decoded = foundExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "per_page"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "total"', () => { + const payload = getFoundExceptionListSchemaMock(); + delete payload.total; + const decoded = foundExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "total"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT accept an undefined for "data"', () => { + const payload = getFoundExceptionListSchemaMock(); + delete payload.data; + const decoded = foundExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "data"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: FoundExceptionListSchema & { + extraKey?: string; + } = getFoundExceptionListSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = foundExceptionListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/comments.mock.ts b/x-pack/plugins/lists/common/schemas/types/comments.mock.ts new file mode 100644 index 00000000000000..ee58fafe074c7b --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/comments.mock.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DATE_NOW, USER } from '../../constants.mock'; + +import { CommentsArray } from './comments'; + +export const getCommentsMock = (): CommentsArray => [ + { + comment: 'some comment', + created_at: DATE_NOW, + created_by: USER, + }, + { + comment: 'some other comment', + created_at: DATE_NOW, + created_by: 'lily', + }, +]; diff --git a/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts b/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts index a80bb968561f0f..e824d481b36188 100644 --- a/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts +++ b/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts @@ -27,8 +27,8 @@ export const DefaultCommentsArray: DefaultCommentsArrayC = new t.Type< >( 'DefaultCommentsArray', t.array(comment).is, - (input): Either => - input == null ? t.success([]) : t.array(comment).decode(input), + (input, context): Either => + input == null ? t.success([]) : t.array(comment).validate(input, context), t.identity ); @@ -43,7 +43,7 @@ export const DefaultCommentsPartialArray: DefaultCommentsPartialArrayC = new t.T >( 'DefaultCommentsPartialArray', t.array(commentPartial).is, - (input): Either => - input == null ? t.success([]) : t.array(commentPartial).decode(input), + (input, context): Either => + input == null ? t.success([]) : t.array(commentPartial).validate(input, context), t.identity ); diff --git a/x-pack/plugins/lists/common/schemas/types/default_entries_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_entries_array.test.ts new file mode 100644 index 00000000000000..9e615528ba7755 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_entries_array.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { DefaultEntryArray } from './default_entries_array'; +import { EntriesArray } from './entries'; +import { getEntriesArrayMock, getEntryMatchMock, getEntryNestedMock } from './entries.mock'; + +// NOTE: This may seem weird, but when validating schemas that use a union +// it checks against every item in that union. Since entries consist of 5 +// different entry types, it returns 5 of these. To make more readable, +// extracted here. +const returnedSchemaError = `"Array<({| field: string, operator: "excluded" | "included", type: "match", value: string |} | {| field: string, operator: "excluded" | "included", type: "match_any", value: DefaultStringArray |} | {| field: string, operator: "excluded" | "included", type: "list", value: DefaultStringArray |} | {| field: string, operator: "excluded" | "included", type: "exists" |} | {| entries: Array<({| field: string, operator: "excluded" | "included", type: "match", value: string |} | {| field: string, operator: "excluded" | "included", type: "match_any", value: DefaultStringArray |} | {| field: string, operator: "excluded" | "included", type: "list", value: DefaultStringArray |} | {| field: string, operator: "excluded" | "included", type: "exists" |})>, field: string, type: "nested" |})>"`; + +describe('default_entries_array', () => { + test('it should validate an empty array', () => { + const payload: EntriesArray = []; + const decoded = DefaultEntryArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of regular and nested entries', () => { + const payload: EntriesArray = getEntriesArrayMock(); + const decoded = DefaultEntryArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of nested entries', () => { + const payload: EntriesArray = [{ ...getEntryNestedMock() }]; + const decoded = DefaultEntryArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of non nested entries', () => { + const payload: EntriesArray = [{ ...getEntryMatchMock() }]; + const decoded = DefaultEntryArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate an array of numbers', () => { + const payload = [1]; + const decoded = DefaultEntryArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + // TODO: Known weird error formatting that is on our list to address + expect(getPaths(left(message.errors))).toEqual([ + `Invalid value "1" supplied to ${returnedSchemaError}`, + `Invalid value "1" supplied to ${returnedSchemaError}`, + `Invalid value "1" supplied to ${returnedSchemaError}`, + `Invalid value "1" supplied to ${returnedSchemaError}`, + `Invalid value "1" supplied to ${returnedSchemaError}`, + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an array of strings', () => { + const payload = ['some string']; + const decoded = DefaultEntryArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + `Invalid value "some string" supplied to ${returnedSchemaError}`, + `Invalid value "some string" supplied to ${returnedSchemaError}`, + `Invalid value "some string" supplied to ${returnedSchemaError}`, + `Invalid value "some string" supplied to ${returnedSchemaError}`, + `Invalid value "some string" supplied to ${returnedSchemaError}`, + ]); + expect(message.schema).toEqual({}); + }); + + test('it should return a default array entry', () => { + const payload = null; + const decoded = DefaultEntryArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_entries_array.ts b/x-pack/plugins/lists/common/schemas/types/default_entries_array.ts index 43698665bb3718..da67fb286affa2 100644 --- a/x-pack/plugins/lists/common/schemas/types/default_entries_array.ts +++ b/x-pack/plugins/lists/common/schemas/types/default_entries_array.ts @@ -7,7 +7,7 @@ import * as t from 'io-ts'; import { Either } from 'fp-ts/lib/Either'; -import { EntriesArray, entries } from './entries'; +import { EntriesArray, entriesArray } from './entries'; export type DefaultEntriesArrayC = t.Type; @@ -21,8 +21,8 @@ export const DefaultEntryArray: DefaultEntriesArrayC = new t.Type< unknown >( 'DefaultEntryArray', - t.array(entries).is, + entriesArray.is, (input): Either => - input == null ? t.success([]) : t.array(entries).decode(input), + input == null ? t.success([]) : entriesArray.decode(input), t.identity ); diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts index ebe2cd60cf6c88..c98cb8d2bba72c 100644 --- a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts @@ -24,7 +24,7 @@ export const DefaultNamespace: DefaultNamespaceC = new t.Type< >( 'DefaultNamespace', namespaceType.is, - (input): Either => - input == null ? t.success('single') : namespaceType.decode(input), + (input, context): Either => + input == null ? t.success('single') : namespaceType.validate(input, context), t.identity ); diff --git a/x-pack/plugins/lists/common/schemas/types/entries.mock.ts b/x-pack/plugins/lists/common/schemas/types/entries.mock.ts new file mode 100644 index 00000000000000..1926cb09db119a --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entries.mock.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ENTRY_VALUE, + EXISTS, + FIELD, + LIST, + MATCH, + MATCH_ANY, + NESTED, + OPERATOR, +} from '../../constants.mock'; + +import { + EntriesArray, + EntryExists, + EntryList, + EntryMatch, + EntryMatchAny, + EntryNested, +} from './entries'; + +export const getEntryMatchMock = (): EntryMatch => ({ + field: FIELD, + operator: OPERATOR, + type: MATCH, + value: ENTRY_VALUE, +}); + +export const getEntryMatchAnyMock = (): EntryMatchAny => ({ + field: FIELD, + operator: OPERATOR, + type: MATCH_ANY, + value: [ENTRY_VALUE], +}); + +export const getEntryListMock = (): EntryList => ({ + field: FIELD, + operator: OPERATOR, + type: LIST, + value: [ENTRY_VALUE], +}); + +export const getEntryExistsMock = (): EntryExists => ({ + field: FIELD, + operator: OPERATOR, + type: EXISTS, +}); + +export const getEntryNestedMock = (): EntryNested => ({ + entries: [getEntryMatchMock(), getEntryExistsMock()], + field: FIELD, + type: NESTED, +}); + +export const getEntriesArrayMock = (): EntriesArray => [ + getEntryMatchMock(), + getEntryMatchAnyMock(), + getEntryListMock(), + getEntryExistsMock(), + getEntryNestedMock(), +]; diff --git a/x-pack/plugins/lists/common/schemas/types/entries.test.ts b/x-pack/plugins/lists/common/schemas/types/entries.test.ts new file mode 100644 index 00000000000000..a13d4c0347e455 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entries.test.ts @@ -0,0 +1,353 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { + getEntryExistsMock, + getEntryListMock, + getEntryMatchAnyMock, + getEntryMatchMock, + getEntryNestedMock, +} from './entries.mock'; +import { + EntryExists, + EntryList, + EntryMatch, + EntryMatchAny, + EntryNested, + entriesExists, + entriesList, + entriesMatch, + entriesMatchAny, + entriesNested, +} from './entries'; + +describe('Entries', () => { + describe('entriesMatch', () => { + test('it should validate an entry', () => { + const payload = getEntryMatchMock(); + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when operator is "included"', () => { + const payload = getEntryMatchMock(); + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when "operator" is "excluded"', () => { + const payload = getEntryMatchMock(); + payload.operator = 'excluded'; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when "value" is not string', () => { + const payload: Omit & { value: string[] } = { + ...getEntryMatchMock(), + value: ['some value'], + }; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "["some value"]" supplied to "value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "type" is not "match"', () => { + const payload: Omit & { type: string } = { + ...getEntryMatchMock(), + type: 'match_any', + }; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "match_any" supplied to "type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EntryMatch & { + extraKey?: string; + } = getEntryMatchMock(); + payload.extraKey = 'some value'; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getEntryMatchMock()); + }); + }); + + describe('entriesMatchAny', () => { + test('it should validate an entry', () => { + const payload = getEntryMatchAnyMock(); + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when operator is "included"', () => { + const payload = getEntryMatchAnyMock(); + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when operator is "excluded"', () => { + const payload = getEntryMatchAnyMock(); + payload.operator = 'excluded'; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when value is not string array', () => { + const payload: Omit & { value: string } = { + ...getEntryMatchAnyMock(), + value: 'some string', + }; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "type" is not "match_any"', () => { + const payload: Omit & { type: string } = { + ...getEntryMatchAnyMock(), + type: 'match', + }; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EntryMatchAny & { + extraKey?: string; + } = getEntryMatchAnyMock(); + payload.extraKey = 'some extra key'; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getEntryMatchAnyMock()); + }); + }); + + describe('entriesExists', () => { + test('it should validate an entry', () => { + const payload = getEntryExistsMock(); + const decoded = entriesExists.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when "operator" is "included"', () => { + const payload = getEntryExistsMock(); + const decoded = entriesExists.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when "operator" is "excluded"', () => { + const payload = getEntryExistsMock(); + payload.operator = 'excluded'; + const decoded = entriesExists.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should strip out extra keys', () => { + const payload: EntryExists & { + extraKey?: string; + } = getEntryExistsMock(); + payload.extraKey = 'some extra key'; + const decoded = entriesExists.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getEntryExistsMock()); + }); + + test('it should not validate when "type" is not "exists"', () => { + const payload: Omit & { type: string } = { + ...getEntryExistsMock(), + type: 'match', + }; + const decoded = entriesExists.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); + expect(message.schema).toEqual({}); + }); + }); + + describe('entriesList', () => { + test('it should validate an entry', () => { + const payload = getEntryListMock(); + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when operator is "included"', () => { + const payload = getEntryListMock(); + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when "operator" is "excluded"', () => { + const payload = getEntryListMock(); + payload.operator = 'excluded'; + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when "value" is not string array', () => { + const payload: Omit & { value: string } = { + ...getEntryListMock(), + value: 'someListId', + }; + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "someListId" supplied to "value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "type" is not "lists"', () => { + const payload: Omit & { type: 'match_any' } = { + ...getEntryListMock(), + type: 'match_any', + }; + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "match_any" supplied to "type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EntryList & { + extraKey?: string; + } = getEntryListMock(); + payload.extraKey = 'some extra key'; + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getEntryListMock()); + }); + }); + + describe('entriesNested', () => { + test('it should validate a nested entry', () => { + const payload = getEntryNestedMock(); + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate when "type" is not "nested"', () => { + const payload: Omit & { type: 'match' } = { + ...getEntryNestedMock(), + type: 'match', + }; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate when "field" is not a string', () => { + const payload: Omit & { + field: number; + } = { ...getEntryNestedMock(), field: 1 }; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "1" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate when "entries" is not a an array', () => { + const payload: Omit & { + entries: string; + } = { ...getEntryNestedMock(), entries: 'im a string' }; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "im a string" supplied to "entries"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EntryNested & { + extraKey?: string; + } = getEntryNestedMock(); + payload.extraKey = 'some extra key'; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getEntryNestedMock()); + }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entries.ts b/x-pack/plugins/lists/common/schemas/types/entries.ts index 750e04e2f39e53..e3625dbe083346 100644 --- a/x-pack/plugins/lists/common/schemas/types/entries.ts +++ b/x-pack/plugins/lists/common/schemas/types/entries.ts @@ -3,19 +3,67 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +/* eslint-disable @typescript-eslint/camelcase */ + import * as t from 'io-ts'; -export const entries = t.exact( +import { operator } from '../common/schemas'; +import { DefaultStringArray } from '../../siem_common_deps'; + +export const entriesMatch = t.exact( + t.type({ + field: t.string, + operator, + type: t.keyof({ match: null }), + value: t.string, + }) +); +export type EntryMatch = t.TypeOf; + +export const entriesMatchAny = t.exact( + t.type({ + field: t.string, + operator, + type: t.keyof({ match_any: null }), + value: DefaultStringArray, + }) +); +export type EntryMatchAny = t.TypeOf; + +export const entriesList = t.exact( + t.type({ + field: t.string, + operator, + type: t.keyof({ list: null }), + value: DefaultStringArray, + }) +); +export type EntryList = t.TypeOf; + +export const entriesExists = t.exact( t.type({ field: t.string, - match: t.union([t.string, t.undefined]), - match_any: t.union([t.array(t.string), t.undefined]), - operator: t.string, // TODO: Use a key of with all possible values + operator, + type: t.keyof({ exists: null }), }) ); +export type EntryExists = t.TypeOf; -export const entriesArray = t.array(entries); +export const entriesNested = t.exact( + t.type({ + entries: t.array(t.union([entriesMatch, entriesMatchAny, entriesList, entriesExists])), + field: t.string, + type: t.keyof({ nested: null }), + }) +); +export type EntryNested = t.TypeOf; + +export const entry = t.union([entriesMatch, entriesMatchAny, entriesList, entriesExists]); +export type Entry = t.TypeOf; +export const entriesArray = t.array( + t.union([entriesMatch, entriesMatchAny, entriesList, entriesExists, entriesNested]) +); export type EntriesArray = t.TypeOf; -export type Entries = t.TypeOf; export const entriesArrayOrUndefined = t.union([entriesArray, t.undefined]); export type EntriesArrayOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/lists/public/exceptions/__mocks__/api.ts b/x-pack/plugins/lists/public/exceptions/__mocks__/api.ts deleted file mode 100644 index 9651be0d04e8c8..00000000000000 --- a/x-pack/plugins/lists/public/exceptions/__mocks__/api.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; -import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; -import { - ExceptionListItemSchema, - ExceptionListSchema, - FoundExceptionListItemSchema, -} from '../../../common/schemas'; -import { - AddExceptionListItemProps, - AddExceptionListProps, - ApiCallByIdProps, - ApiCallByListIdProps, -} from '../types'; - -/* eslint-disable @typescript-eslint/no-unused-vars */ -export const addExceptionList = async ({ - http, - list, - signal, -}: AddExceptionListProps): Promise => - Promise.resolve(getExceptionListSchemaMock()); - -export const addExceptionListItem = async ({ - http, - listItem, - signal, -}: AddExceptionListItemProps): Promise => - Promise.resolve(getExceptionListItemSchemaMock()); - -export const fetchExceptionListById = async ({ - http, - id, - signal, -}: ApiCallByIdProps): Promise => Promise.resolve(getExceptionListSchemaMock()); - -export const fetchExceptionListItemsByListId = async ({ - filterOptions, - http, - listId, - pagination, - signal, -}: ApiCallByListIdProps): Promise => - Promise.resolve({ data: [getExceptionListItemSchemaMock()], page: 1, per_page: 20, total: 1 }); - -export const fetchExceptionListItemById = async ({ - http, - id, - signal, -}: ApiCallByIdProps): Promise => - Promise.resolve(getExceptionListItemSchemaMock()); - -export const deleteExceptionListById = async ({ - http, - id, - namespaceType, - signal, -}: ApiCallByIdProps): Promise => Promise.resolve(getExceptionListSchemaMock()); - -export const deleteExceptionListItemById = async ({ - http, - id, - namespaceType, - signal, -}: ApiCallByIdProps): Promise => - Promise.resolve(getExceptionListItemSchemaMock()); diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index 66c92e0823d620..72a689650ea2d3 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.test.ts @@ -8,6 +8,15 @@ import { getExceptionListSchemaMock } from '../../common/schemas/response/except import { getExceptionListItemSchemaMock } from '../../common/schemas/response/exception_list_item_schema.mock'; import { getCreateExceptionListSchemaMock } from '../../common/schemas/request/create_exception_list_schema.mock'; import { getCreateExceptionListItemSchemaMock } from '../../common/schemas/request/create_exception_list_item_schema.mock'; +import { getFoundExceptionListItemSchemaMock } from '../../common/schemas/response/found_exception_list_item_schema.mock'; +import { getUpdateExceptionListItemSchemaMock } from '../../common/schemas/request/update_exception_list_item_schema.mock'; +import { getUpdateExceptionListSchemaMock } from '../../common/schemas/request/update_exception_list_schema.mock'; +import { + CreateExceptionListItemSchema, + CreateExceptionListSchema, + ExceptionListItemSchema, + ExceptionListSchema, +} from '../../common/schemas'; import { addExceptionList, @@ -17,7 +26,10 @@ import { fetchExceptionListById, fetchExceptionListItemById, fetchExceptionListItemsByListId, + updateExceptionList, + updateExceptionListItem, } from './api'; +import { ApiCallByIdProps, ApiCallByListIdProps } from './types'; const abortCtrl = new AbortController(); @@ -44,37 +56,60 @@ describe('Exceptions Lists API', () => { fetchMock.mockResolvedValue(getExceptionListSchemaMock()); }); - test('it uses POST when "list.id" does not exist', async () => { + test('it invokes "addExceptionList" with expected url and body values', async () => { const payload = getCreateExceptionListSchemaMock(); - const exceptionResponse = await addExceptionList({ + await addExceptionList({ http: mockKibanaHttpService(), list: payload, signal: abortCtrl.signal, }); - + // TODO Would like to just use getExceptionListSchemaMock() here, but + // validation returns object in different order, making the strings not match expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', { body: JSON.stringify(payload), method: 'POST', signal: abortCtrl.signal, }); - expect(exceptionResponse).toEqual(getExceptionListSchemaMock()); }); - test('it uses PUT when "list.id" exists', async () => { - const payload = getExceptionListSchemaMock(); + test('it returns expected exception list on success', async () => { + const payload = getCreateExceptionListSchemaMock(); const exceptionResponse = await addExceptionList({ http: mockKibanaHttpService(), - list: getExceptionListSchemaMock(), - signal: abortCtrl.signal, - }); - - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', { - body: JSON.stringify(payload), - method: 'PUT', + list: payload, signal: abortCtrl.signal, }); expect(exceptionResponse).toEqual(getExceptionListSchemaMock()); }); + + test('it returns error and does not make request if request payload fails decode', async () => { + const payload: Omit & { + description?: string[]; + } = { ...getCreateExceptionListSchemaMock(), description: ['123'] }; + + await expect( + addExceptionList({ + http: mockKibanaHttpService(), + list: (payload as unknown) as ExceptionListSchema, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "["123"]" supplied to "description"'); + }); + + test('it returns error if response payload fails decode', async () => { + const payload = getCreateExceptionListSchemaMock(); + const badPayload = getExceptionListSchemaMock(); + delete badPayload.id; + fetchMock.mockResolvedValue(badPayload); + + await expect( + addExceptionList({ + http: mockKibanaHttpService(), + list: payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + }); }); describe('#addExceptionListItem', () => { @@ -83,37 +118,182 @@ describe('Exceptions Lists API', () => { fetchMock.mockResolvedValue(getExceptionListItemSchemaMock()); }); - test('it uses POST when "listItem.id" does not exist', async () => { + test('it invokes "addExceptionListItem" with expected url and body values', async () => { const payload = getCreateExceptionListItemSchemaMock(); - const exceptionResponse = await addExceptionListItem({ + await addExceptionListItem({ http: mockKibanaHttpService(), listItem: payload, signal: abortCtrl.signal, }); - + // TODO Would like to just use getExceptionListSchemaMock() here, but + // validation returns object in different order, making the strings not match expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', { body: JSON.stringify(payload), method: 'POST', signal: abortCtrl.signal, }); - expect(exceptionResponse).toEqual(getExceptionListItemSchemaMock()); }); - test('check parameter url, body when "listItem.id" exists', async () => { - const payload = getExceptionListItemSchemaMock(); + test('it returns expected exception list on success', async () => { + const payload = getCreateExceptionListItemSchemaMock(); const exceptionResponse = await addExceptionListItem({ http: mockKibanaHttpService(), - listItem: getExceptionListItemSchemaMock(), + listItem: payload, signal: abortCtrl.signal, }); + expect(exceptionResponse).toEqual(getExceptionListItemSchemaMock()); + }); - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', { + test('it returns error and does not make request if request payload fails decode', async () => { + const payload: Omit & { + description?: string[]; + } = { ...getCreateExceptionListItemSchemaMock(), description: ['123'] }; + + await expect( + addExceptionListItem({ + http: mockKibanaHttpService(), + listItem: (payload as unknown) as ExceptionListItemSchema, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "["123"]" supplied to "description"'); + }); + + test('it returns error if response payload fails decode', async () => { + const payload = getCreateExceptionListItemSchemaMock(); + const badPayload = getExceptionListItemSchemaMock(); + delete badPayload.id; + fetchMock.mockResolvedValue(badPayload); + + await expect( + addExceptionListItem({ + http: mockKibanaHttpService(), + listItem: payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + }); + }); + + describe('#updateExceptionList', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(getExceptionListSchemaMock()); + }); + + test('it invokes "updateExceptionList" with expected url and body values', async () => { + const payload = getUpdateExceptionListSchemaMock(); + await updateExceptionList({ + http: mockKibanaHttpService(), + list: payload, + signal: abortCtrl.signal, + }); + // TODO Would like to just use getExceptionListSchemaMock() here, but + // validation returns object in different order, making the strings not match + expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', { + body: JSON.stringify(payload), + method: 'PUT', + signal: abortCtrl.signal, + }); + }); + + test('it returns expected exception list on success', async () => { + const payload = getUpdateExceptionListSchemaMock(); + const exceptionResponse = await updateExceptionList({ + http: mockKibanaHttpService(), + list: payload, + signal: abortCtrl.signal, + }); + expect(exceptionResponse).toEqual(getExceptionListSchemaMock()); + }); + + test('it returns error and does not make request if request payload fails decode', async () => { + const payload = getUpdateExceptionListSchemaMock(); + delete payload.description; + + await expect( + updateExceptionList({ + http: mockKibanaHttpService(), + list: payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "description"'); + }); + + test('it returns error if response payload fails decode', async () => { + const payload = getUpdateExceptionListSchemaMock(); + const badPayload = getExceptionListSchemaMock(); + delete badPayload.id; + fetchMock.mockResolvedValue(badPayload); + + await expect( + updateExceptionList({ + http: mockKibanaHttpService(), + list: payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + }); + }); + + describe('#updateExceptionListItem', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(getExceptionListItemSchemaMock()); + }); + + test('it invokes "updateExceptionListItem" with expected url and body values', async () => { + const payload = getUpdateExceptionListItemSchemaMock(); + await updateExceptionListItem({ + http: mockKibanaHttpService(), + listItem: payload, + signal: abortCtrl.signal, + }); + // TODO Would like to just use getExceptionListSchemaMock() here, but + // validation returns object in different order, making the strings not match + expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', { body: JSON.stringify(payload), method: 'PUT', signal: abortCtrl.signal, }); + }); + + test('it returns expected exception list on success', async () => { + const payload = getUpdateExceptionListItemSchemaMock(); + const exceptionResponse = await updateExceptionListItem({ + http: mockKibanaHttpService(), + listItem: payload, + signal: abortCtrl.signal, + }); expect(exceptionResponse).toEqual(getExceptionListItemSchemaMock()); }); + + test('it returns error and does not make request if request payload fails decode', async () => { + const payload = getUpdateExceptionListItemSchemaMock(); + delete payload.description; + + await expect( + updateExceptionListItem({ + http: mockKibanaHttpService(), + listItem: payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "description"'); + }); + + test('it returns error if response payload fails decode', async () => { + const payload = getUpdateExceptionListItemSchemaMock(); + const badPayload = getExceptionListItemSchemaMock(); + delete badPayload.id; + fetchMock.mockResolvedValue(badPayload); + + await expect( + updateExceptionListItem({ + http: mockKibanaHttpService(), + listItem: payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + }); }); describe('#fetchExceptionListById', () => { @@ -148,12 +328,39 @@ describe('Exceptions Lists API', () => { }); expect(exceptionResponse).toEqual(getExceptionListSchemaMock()); }); + + test('it returns error and does not make request if request payload fails decode', async () => { + const payload = ({ + http: mockKibanaHttpService(), + id: 1, + namespaceType: 'single', + signal: abortCtrl.signal, + } as unknown) as ApiCallByIdProps & { id: number }; + await expect(fetchExceptionListById(payload)).rejects.toEqual( + 'Invalid value "1" supplied to "id"' + ); + }); + + test('it returns error if response payload fails decode', async () => { + const badPayload = getExceptionListItemSchemaMock(); + delete badPayload.id; + fetchMock.mockResolvedValue(badPayload); + + await expect( + fetchExceptionListById({ + http: mockKibanaHttpService(), + id: '1', + namespaceType: 'single', + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + }); }); describe('#fetchExceptionListItemsByListId', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue([getExceptionListItemSchemaMock()]); + fetchMock.mockResolvedValue(getFoundExceptionListItemSchemaMock()); }); test('it invokes "fetchExceptionListItemsByListId" with expected url and body values', async () => { @@ -161,6 +368,10 @@ describe('Exceptions Lists API', () => { http: mockKibanaHttpService(), listId: 'myList', namespaceType: 'single', + pagination: { + page: 1, + perPage: 20, + }, signal: abortCtrl.signal, }); @@ -169,8 +380,8 @@ describe('Exceptions Lists API', () => { query: { list_id: 'myList', namespace_type: 'single', - page: 1, - per_page: 20, + page: '1', + per_page: '20', }, signal: abortCtrl.signal, }); @@ -185,6 +396,10 @@ describe('Exceptions Lists API', () => { http: mockKibanaHttpService(), listId: 'myList', namespaceType: 'single', + pagination: { + page: 1, + perPage: 20, + }, signal: abortCtrl.signal, }); @@ -194,8 +409,8 @@ describe('Exceptions Lists API', () => { filter: 'exception-list.attributes.entries.field:hello world*', list_id: 'myList', namespace_type: 'single', - page: 1, - per_page: 20, + page: '1', + per_page: '20', }, signal: abortCtrl.signal, }); @@ -210,6 +425,10 @@ describe('Exceptions Lists API', () => { http: mockKibanaHttpService(), listId: 'myList', namespaceType: 'agnostic', + pagination: { + page: 1, + perPage: 20, + }, signal: abortCtrl.signal, }); @@ -219,8 +438,8 @@ describe('Exceptions Lists API', () => { filter: 'exception-list-agnostic.attributes.entries.field:hello world*', list_id: 'myList', namespace_type: 'agnostic', - page: 1, - per_page: 20, + page: '1', + per_page: '20', }, signal: abortCtrl.signal, }); @@ -235,6 +454,10 @@ describe('Exceptions Lists API', () => { http: mockKibanaHttpService(), listId: 'myList', namespaceType: 'agnostic', + pagination: { + page: 1, + perPage: 20, + }, signal: abortCtrl.signal, }); @@ -244,8 +467,8 @@ describe('Exceptions Lists API', () => { filter: 'exception-list-agnostic.attributes.tags:malware', list_id: 'myList', namespace_type: 'agnostic', - page: 1, - per_page: 20, + page: '1', + per_page: '20', }, signal: abortCtrl.signal, }); @@ -260,6 +483,10 @@ describe('Exceptions Lists API', () => { http: mockKibanaHttpService(), listId: 'myList', namespaceType: 'agnostic', + pagination: { + page: 1, + perPage: 20, + }, signal: abortCtrl.signal, }); @@ -270,8 +497,8 @@ describe('Exceptions Lists API', () => { 'exception-list-agnostic.attributes.entries.field:host.name* AND exception-list-agnostic.attributes.tags:malware', list_id: 'myList', namespace_type: 'agnostic', - page: 1, - per_page: 20, + page: '1', + per_page: '20', }, signal: abortCtrl.signal, }); @@ -282,16 +509,57 @@ describe('Exceptions Lists API', () => { http: mockKibanaHttpService(), listId: 'endpoint_list', namespaceType: 'single', + pagination: { + page: 1, + perPage: 20, + }, signal: abortCtrl.signal, }); - expect(exceptionResponse).toEqual([getExceptionListItemSchemaMock()]); + expect(exceptionResponse).toEqual(getFoundExceptionListItemSchemaMock()); + }); + + test('it returns error and does not make request if request payload fails decode', async () => { + const payload = ({ + http: mockKibanaHttpService(), + listId: '1', + namespaceType: 'not a namespace type', + pagination: { + page: 1, + perPage: 20, + }, + signal: abortCtrl.signal, + } as unknown) as ApiCallByListIdProps & { listId: number }; + await expect(fetchExceptionListItemsByListId(payload)).rejects.toEqual( + 'Invalid value "not a namespace type" supplied to "namespace_type"' + ); + }); + + test('it returns error if response payload fails decode', async () => { + const badPayload = getExceptionListItemSchemaMock(); + delete badPayload.id; + fetchMock.mockResolvedValue(badPayload); + + await expect( + fetchExceptionListItemsByListId({ + http: mockKibanaHttpService(), + listId: 'myList', + namespaceType: 'single', + pagination: { + page: 1, + perPage: 20, + }, + signal: abortCtrl.signal, + }) + ).rejects.toEqual( + 'Invalid value "undefined" supplied to "data",Invalid value "undefined" supplied to "page",Invalid value "undefined" supplied to "per_page",Invalid value "undefined" supplied to "total"' + ); }); }); describe('#fetchExceptionListItemById', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue([getExceptionListItemSchemaMock()]); + fetchMock.mockResolvedValue(getExceptionListItemSchemaMock()); }); test('it invokes "fetchExceptionListItemById" with expected url and body values', async () => { @@ -318,7 +586,34 @@ describe('Exceptions Lists API', () => { namespaceType: 'single', signal: abortCtrl.signal, }); - expect(exceptionResponse).toEqual([getExceptionListItemSchemaMock()]); + expect(exceptionResponse).toEqual(getExceptionListItemSchemaMock()); + }); + + test('it returns error and does not make request if request payload fails decode', async () => { + const payload = ({ + http: mockKibanaHttpService(), + id: '1', + namespaceType: 'not a namespace type', + signal: abortCtrl.signal, + } as unknown) as ApiCallByIdProps & { namespaceType: string }; + await expect(fetchExceptionListItemById(payload)).rejects.toEqual( + 'Invalid value "not a namespace type" supplied to "namespace_type"' + ); + }); + + test('it returns error if response payload fails decode', async () => { + const badPayload = getExceptionListItemSchemaMock(); + delete badPayload.id; + fetchMock.mockResolvedValue(badPayload); + + await expect( + fetchExceptionListItemById({ + http: mockKibanaHttpService(), + id: '1', + namespaceType: 'single', + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); }); }); @@ -354,6 +649,33 @@ describe('Exceptions Lists API', () => { }); expect(exceptionResponse).toEqual(getExceptionListSchemaMock()); }); + + test('it returns error and does not make request if request payload fails decode', async () => { + const payload = ({ + http: mockKibanaHttpService(), + id: 1, + namespaceType: 'single', + signal: abortCtrl.signal, + } as unknown) as ApiCallByIdProps & { id: number }; + await expect(deleteExceptionListById(payload)).rejects.toEqual( + 'Invalid value "1" supplied to "id"' + ); + }); + + test('it returns error if response payload fails decode', async () => { + const badPayload = getExceptionListSchemaMock(); + delete badPayload.id; + fetchMock.mockResolvedValue(badPayload); + + await expect( + deleteExceptionListById({ + http: mockKibanaHttpService(), + id: '1', + namespaceType: 'single', + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + }); }); describe('#deleteExceptionListItemById', () => { @@ -388,5 +710,32 @@ describe('Exceptions Lists API', () => { }); expect(exceptionResponse).toEqual(getExceptionListItemSchemaMock()); }); + + test('it returns error and does not make request if request payload fails decode', async () => { + const payload = ({ + http: mockKibanaHttpService(), + id: 1, + namespaceType: 'single', + signal: abortCtrl.signal, + } as unknown) as ApiCallByIdProps & { id: number }; + await expect(deleteExceptionListItemById(payload)).rejects.toEqual( + 'Invalid value "1" supplied to "id"' + ); + }); + + test('it returns error if response payload fails decode', async () => { + const badPayload = getExceptionListItemSchemaMock(); + delete badPayload.id; + fetchMock.mockResolvedValue(badPayload); + + await expect( + deleteExceptionListItemById({ + http: mockKibanaHttpService(), + id: '1', + namespaceType: 'single', + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + }); }); }); diff --git a/x-pack/plugins/lists/public/exceptions/api.ts b/x-pack/plugins/lists/public/exceptions/api.ts index 6968ba5f50e727..2ab7695d8c17c0 100644 --- a/x-pack/plugins/lists/public/exceptions/api.ts +++ b/x-pack/plugins/lists/public/exceptions/api.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_NAMESPACE, @@ -14,17 +13,32 @@ import { ExceptionListItemSchema, ExceptionListSchema, FoundExceptionListItemSchema, + createExceptionListItemSchema, + createExceptionListSchema, + deleteExceptionListItemSchema, + deleteExceptionListSchema, + exceptionListItemSchema, + exceptionListSchema, + findExceptionListItemSchema, + foundExceptionListItemSchema, + readExceptionListItemSchema, + readExceptionListSchema, + updateExceptionListItemSchema, + updateExceptionListSchema, } from '../../common/schemas'; +import { validate } from '../../common/siem_common_deps'; import { AddExceptionListItemProps, AddExceptionListProps, ApiCallByIdProps, ApiCallByListIdProps, + UpdateExceptionListItemProps, + UpdateExceptionListProps, } from './types'; /** - * Add provided ExceptionList + * Add new ExceptionList * * @param http Kibana http service * @param list exception list to add @@ -32,22 +46,39 @@ import { * * @throws An error if response is not OK * - * Uses type assertion (list as ExceptionListSchema) - * per suggestion in Typescript union docs */ export const addExceptionList = async ({ http, list, signal, -}: AddExceptionListProps): Promise => - http.fetch(EXCEPTION_LIST_URL, { - body: JSON.stringify(list), - method: (list as ExceptionListSchema).id != null ? 'PUT' : 'POST', - signal, - }); +}: AddExceptionListProps): Promise => { + const [validatedRequest, errorsRequest] = validate(list, createExceptionListSchema); + + if (validatedRequest != null) { + try { + const response = await http.fetch(EXCEPTION_LIST_URL, { + body: JSON.stringify(list), + method: 'POST', + signal, + }); + + const [validatedResponse, errorsResponse] = validate(response, exceptionListSchema); + + if (errorsResponse != null || validatedResponse == null) { + return Promise.reject(errorsResponse); + } else { + return Promise.resolve(validatedResponse); + } + } catch (error) { + return Promise.reject(error); + } + } else { + return Promise.reject(errorsRequest); + } +}; /** - * Add provided ExceptionListItem + * Add new ExceptionListItem * * @param http Kibana http service * @param listItem exception list item to add @@ -55,19 +86,116 @@ export const addExceptionList = async ({ * * @throws An error if response is not OK * - * Uses type assertion (listItem as ExceptionListItemSchema) - * per suggestion in Typescript union docs */ export const addExceptionListItem = async ({ http, listItem, signal, -}: AddExceptionListItemProps): Promise => - http.fetch(`${EXCEPTION_LIST_ITEM_URL}`, { - body: JSON.stringify(listItem), - method: (listItem as ExceptionListItemSchema).id != null ? 'PUT' : 'POST', - signal, - }); +}: AddExceptionListItemProps): Promise => { + const [validatedRequest, errorsRequest] = validate(listItem, createExceptionListItemSchema); + + if (validatedRequest != null) { + try { + const response = await http.fetch(EXCEPTION_LIST_ITEM_URL, { + body: JSON.stringify(listItem), + method: 'POST', + signal, + }); + + const [validatedResponse, errorsResponse] = validate(response, exceptionListItemSchema); + + if (errorsResponse != null || validatedResponse == null) { + return Promise.reject(errorsResponse); + } else { + return Promise.resolve(validatedResponse); + } + } catch (error) { + return Promise.reject(error); + } + } else { + return Promise.reject(errorsRequest); + } +}; + +/** + * Update existing ExceptionList + * + * @param http Kibana http service + * @param list exception list to add + * @param signal to cancel request + * + * @throws An error if response is not OK + * + */ +export const updateExceptionList = async ({ + http, + list, + signal, +}: UpdateExceptionListProps): Promise => { + const [validatedRequest, errorsRequest] = validate(list, updateExceptionListSchema); + + if (validatedRequest != null) { + try { + const response = await http.fetch(EXCEPTION_LIST_URL, { + body: JSON.stringify(list), + method: 'PUT', + signal, + }); + + const [validatedResponse, errorsResponse] = validate(response, exceptionListSchema); + + if (errorsResponse != null || validatedResponse == null) { + return Promise.reject(errorsResponse); + } else { + return Promise.resolve(validatedResponse); + } + } catch (error) { + return Promise.reject(error); + } + } else { + return Promise.reject(errorsRequest); + } +}; + +/** + * Update existing ExceptionListItem + * + * @param http Kibana http service + * @param listItem exception list item to add + * @param signal to cancel request + * + * @throws An error if response is not OK + * + */ +export const updateExceptionListItem = async ({ + http, + listItem, + signal, +}: UpdateExceptionListItemProps): Promise => { + const [validatedRequest, errorsRequest] = validate(listItem, updateExceptionListItemSchema); + + if (validatedRequest != null) { + try { + const response = await http.fetch(EXCEPTION_LIST_URL, { + body: JSON.stringify(listItem), + method: 'PUT', + signal, + }); + + const [validatedResponse, errorsResponse] = validate(response, exceptionListItemSchema); + + if (errorsResponse != null || validatedResponse == null) { + return Promise.reject(errorsResponse); + } else { + return Promise.resolve(validatedResponse); + } + } catch (error) { + return Promise.reject(error); + } + } else { + return Promise.reject(errorsRequest); + } +}; /** * Fetch an ExceptionList by providing a ExceptionList ID @@ -84,12 +212,34 @@ export const fetchExceptionListById = async ({ id, namespaceType, signal, -}: ApiCallByIdProps): Promise => - http.fetch(`${EXCEPTION_LIST_URL}`, { - method: 'GET', - query: { id, namespace_type: namespaceType }, - signal, - }); +}: ApiCallByIdProps): Promise => { + const [validatedRequest, errorsRequest] = validate( + { id, namespace_type: namespaceType }, + readExceptionListSchema + ); + + if (validatedRequest != null) { + try { + const response = await http.fetch(EXCEPTION_LIST_URL, { + method: 'GET', + query: { id, namespace_type: namespaceType }, + signal, + }); + + const [validatedResponse, errorsResponse] = validate(response, exceptionListSchema); + + if (errorsResponse != null || validatedResponse == null) { + return Promise.reject(errorsResponse); + } else { + return Promise.resolve(validatedResponse); + } + } catch (error) { + return Promise.reject(error); + } + } else { + return Promise.reject(errorsRequest); + } +}; /** * Fetch an ExceptionList's ExceptionItems by providing a ExceptionList list_id @@ -111,11 +261,7 @@ export const fetchExceptionListItemsByListId = async ({ filter: '', tags: [], }, - pagination = { - page: 1, - perPage: 20, - total: 0, - }, + pagination, signal, }: ApiCallByListIdProps): Promise => { const namespace = @@ -124,22 +270,44 @@ export const fetchExceptionListItemsByListId = async ({ ...(filterOptions.filter.length ? [`${namespace}.attributes.entries.field:${filterOptions.filter}*`] : []), - ...(filterOptions.tags?.map((t) => `${namespace}.attributes.tags:${t}`) ?? []), + ...(filterOptions.tags.length + ? filterOptions.tags.map((t) => `${namespace}.attributes.tags:${t}`) + : []), ]; const query = { list_id: listId, namespace_type: namespaceType, - page: pagination.page, - per_page: pagination.perPage, + page: pagination.page ? `${pagination.page}` : '1', + per_page: pagination.perPage ? `${pagination.perPage}` : '20', ...(filters.length ? { filter: filters.join(' AND ') } : {}), }; + const [validatedRequest, errorsRequest] = validate(query, findExceptionListItemSchema); + + if (validatedRequest != null) { + try { + const response = await http.fetch( + `${EXCEPTION_LIST_ITEM_URL}/_find`, + { + method: 'GET', + query, + signal, + } + ); + + const [validatedResponse, errorsResponse] = validate(response, foundExceptionListItemSchema); - return http.fetch(`${EXCEPTION_LIST_ITEM_URL}/_find`, { - method: 'GET', - query, - signal, - }); + if (errorsResponse != null || validatedResponse == null) { + return Promise.reject(errorsResponse); + } else { + return Promise.resolve(validatedResponse); + } + } catch (error) { + return Promise.reject(error); + } + } else { + return Promise.reject(errorsRequest); + } }; /** @@ -157,12 +325,33 @@ export const fetchExceptionListItemById = async ({ id, namespaceType, signal, -}: ApiCallByIdProps): Promise => - http.fetch(`${EXCEPTION_LIST_ITEM_URL}`, { - method: 'GET', - query: { id, namespace_type: namespaceType }, - signal, - }); +}: ApiCallByIdProps): Promise => { + const [validatedRequest, errorsRequest] = validate( + { id, namespace_type: namespaceType }, + readExceptionListItemSchema + ); + + if (validatedRequest != null) { + try { + const response = await http.fetch(EXCEPTION_LIST_ITEM_URL, { + method: 'GET', + query: { id, namespace_type: namespaceType }, + signal, + }); + const [validatedResponse, errorsResponse] = validate(response, exceptionListItemSchema); + + if (errorsResponse != null || validatedResponse == null) { + return Promise.reject(errorsResponse); + } else { + return Promise.resolve(validatedResponse); + } + } catch (error) { + return Promise.reject(error); + } + } else { + return Promise.reject(errorsRequest); + } +}; /** * Delete an ExceptionList by providing a ExceptionList ID @@ -179,12 +368,34 @@ export const deleteExceptionListById = async ({ id, namespaceType, signal, -}: ApiCallByIdProps): Promise => - http.fetch(`${EXCEPTION_LIST_URL}`, { - method: 'DELETE', - query: { id, namespace_type: namespaceType }, - signal, - }); +}: ApiCallByIdProps): Promise => { + const [validatedRequest, errorsRequest] = validate( + { id, namespace_type: namespaceType }, + deleteExceptionListSchema + ); + + if (validatedRequest != null) { + try { + const response = await http.fetch(EXCEPTION_LIST_URL, { + method: 'DELETE', + query: { id, namespace_type: namespaceType }, + signal, + }); + + const [validatedResponse, errorsResponse] = validate(response, exceptionListSchema); + + if (errorsResponse != null || validatedResponse == null) { + return Promise.reject(errorsResponse); + } else { + return Promise.resolve(validatedResponse); + } + } catch (error) { + return Promise.reject(error); + } + } else { + return Promise.reject(errorsRequest); + } +}; /** * Delete an ExceptionListItem by providing a ExceptionListItem ID @@ -201,9 +412,31 @@ export const deleteExceptionListItemById = async ({ id, namespaceType, signal, -}: ApiCallByIdProps): Promise => - http.fetch(`${EXCEPTION_LIST_ITEM_URL}`, { - method: 'DELETE', - query: { id, namespace_type: namespaceType }, - signal, - }); +}: ApiCallByIdProps): Promise => { + const [validatedRequest, errorsRequest] = validate( + { id, namespace_type: namespaceType }, + deleteExceptionListItemSchema + ); + + if (validatedRequest != null) { + try { + const response = await http.fetch(EXCEPTION_LIST_ITEM_URL, { + method: 'DELETE', + query: { id, namespace_type: namespaceType }, + signal, + }); + + const [validatedResponse, errorsResponse] = validate(response, exceptionListItemSchema); + + if (errorsResponse != null || validatedResponse == null) { + return Promise.reject(errorsResponse); + } else { + return Promise.resolve(validatedResponse); + } + } catch (error) { + return Promise.reject(error); + } + } else { + return Promise.reject(errorsRequest); + } +}; diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.tsx index 1db18168b11fe9..ebee2cbace9cc9 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.test.tsx @@ -7,19 +7,24 @@ import { act, renderHook } from '@testing-library/react-hooks'; import * as api from '../api'; +import { getCreateExceptionListItemSchemaMock } from '../../../common/schemas/request/create_exception_list_item_schema.mock'; +import { getUpdateExceptionListItemSchemaMock } from '../../../common/schemas/request/update_exception_list_item_schema.mock'; import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; import { PersistHookProps } from '../types'; import { ReturnPersistExceptionItem, usePersistExceptionItem } from './persist_exception_item'; -jest.mock('../api'); - const mockKibanaHttpService = createKibanaCoreStartMock().http; describe('usePersistExceptionItem', () => { const onError = jest.fn(); + beforeEach(() => { + jest.spyOn(api, 'addExceptionListItem').mockResolvedValue(getExceptionListItemSchemaMock()); + jest.spyOn(api, 'updateExceptionListItem').mockResolvedValue(getExceptionListItemSchemaMock()); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -40,7 +45,7 @@ describe('usePersistExceptionItem', () => { >(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError })); await waitForNextUpdate(); - result.current[1](getExceptionListItemSchemaMock()); + result.current[1](getCreateExceptionListItemSchemaMock()); rerender(); expect(result.current).toEqual([{ isLoading: true, isSaved: false }, result.current[1]]); @@ -55,10 +60,29 @@ describe('usePersistExceptionItem', () => { >(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError })); await waitForNextUpdate(); - result.current[1](getExceptionListItemSchemaMock()); + result.current[1](getCreateExceptionListItemSchemaMock()); + await waitForNextUpdate(); + + expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); + }); + }); + + test('it invokes "updateExceptionListItem" when payload has "id"', async () => { + const addExceptionItem = jest.spyOn(api, 'addExceptionListItem'); + const updateExceptionItem = jest.spyOn(api, 'updateExceptionListItem'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + PersistHookProps, + ReturnPersistExceptionItem + >(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError })); + + await waitForNextUpdate(); + result.current[1](getUpdateExceptionListItemSchemaMock()); await waitForNextUpdate(); expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); + expect(addExceptionItem).not.toHaveBeenCalled(); + expect(updateExceptionItem).toHaveBeenCalled(); }); }); @@ -73,7 +97,7 @@ describe('usePersistExceptionItem', () => { >(() => usePersistExceptionItem({ http: mockKibanaHttpService, onError })); await waitForNextUpdate(); - result.current[1](getExceptionListItemSchemaMock()); + result.current[1](getCreateExceptionListItemSchemaMock()); await waitForNextUpdate(); expect(result.current).toEqual([{ isLoading: false, isSaved: false }, result.current[1]]); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.tsx b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.tsx index d9fe3a82ac1774..b6a333f1232e0c 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_item.tsx @@ -3,10 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { Dispatch, useEffect, useState } from 'react'; -import { addExceptionListItem as persistExceptionItem } from '../api'; +import { UpdateExceptionListItemSchema } from '../../../common/schemas'; +import { addExceptionListItem, updateExceptionListItem } from '../api'; import { AddExceptionListItem, PersistHookProps } from '../types'; interface PersistReturnExceptionItem { @@ -33,6 +33,8 @@ export const usePersistExceptionItem = ({ const [exceptionListItem, setExceptionItem] = useState(null); const [isSaved, setIsSaved] = useState(false); const [isLoading, setIsLoading] = useState(false); + const isUpdateExceptionItem = (item: unknown): item is UpdateExceptionListItemSchema => + Boolean(item && (item as UpdateExceptionListItemSchema).id != null); useEffect(() => { let isSubscribed = true; @@ -43,11 +45,20 @@ export const usePersistExceptionItem = ({ if (exceptionListItem != null) { try { setIsLoading(true); - await persistExceptionItem({ - http, - listItem: exceptionListItem, - signal: abortCtrl.signal, - }); + if (isUpdateExceptionItem(exceptionListItem)) { + await updateExceptionListItem({ + http, + listItem: exceptionListItem, + signal: abortCtrl.signal, + }); + } else { + await addExceptionListItem({ + http, + listItem: exceptionListItem, + signal: abortCtrl.signal, + }); + } + if (isSubscribed) { setIsSaved(true); } diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.tsx index 80d6e27043c996..0541f893e2797c 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.test.tsx @@ -7,19 +7,24 @@ import { act, renderHook } from '@testing-library/react-hooks'; import * as api from '../api'; +import { getCreateExceptionListSchemaMock } from '../../../common/schemas/request/create_exception_list_schema.mock'; +import { getUpdateExceptionListSchemaMock } from '../../../common/schemas/request/update_exception_list_schema.mock'; import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; import { PersistHookProps } from '../types'; import { ReturnPersistExceptionList, usePersistExceptionList } from './persist_exception_list'; -jest.mock('../api'); - const mockKibanaHttpService = createKibanaCoreStartMock().http; describe('usePersistExceptionList', () => { const onError = jest.fn(); + beforeEach(() => { + jest.spyOn(api, 'addExceptionList').mockResolvedValue(getExceptionListSchemaMock()); + jest.spyOn(api, 'updateExceptionList').mockResolvedValue(getExceptionListSchemaMock()); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -39,7 +44,7 @@ describe('usePersistExceptionList', () => { ReturnPersistExceptionList >(() => usePersistExceptionList({ http: mockKibanaHttpService, onError })); await waitForNextUpdate(); - result.current[1](getExceptionListSchemaMock()); + result.current[1](getCreateExceptionListSchemaMock()); rerender(); expect(result.current).toEqual([{ isLoading: true, isSaved: false }, result.current[1]]); @@ -53,10 +58,29 @@ describe('usePersistExceptionList', () => { ReturnPersistExceptionList >(() => usePersistExceptionList({ http: mockKibanaHttpService, onError })); await waitForNextUpdate(); - result.current[1](getExceptionListSchemaMock()); + result.current[1](getCreateExceptionListSchemaMock()); + await waitForNextUpdate(); + + expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); + }); + }); + + test('it invokes "updateExceptionList" when payload has "id"', async () => { + const addException = jest.spyOn(api, 'addExceptionList'); + const updateException = jest.spyOn(api, 'updateExceptionList'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + PersistHookProps, + ReturnPersistExceptionList + >(() => usePersistExceptionList({ http: mockKibanaHttpService, onError })); + + await waitForNextUpdate(); + result.current[1](getUpdateExceptionListSchemaMock()); await waitForNextUpdate(); expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); + expect(addException).not.toHaveBeenCalled(); + expect(updateException).toHaveBeenCalled(); }); }); @@ -70,7 +94,7 @@ describe('usePersistExceptionList', () => { ReturnPersistExceptionList >(() => usePersistExceptionList({ http: mockKibanaHttpService, onError })); await waitForNextUpdate(); - result.current[1](getExceptionListSchemaMock()); + result.current[1](getCreateExceptionListSchemaMock()); await waitForNextUpdate(); expect(result.current).toEqual([{ isLoading: false, isSaved: false }, result.current[1]]); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.tsx b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.tsx index 5848a171451946..9a82d538fcd794 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/persist_exception_list.tsx @@ -6,7 +6,8 @@ import { Dispatch, useEffect, useState } from 'react'; -import { addExceptionList as persistExceptionList } from '../api'; +import { UpdateExceptionListSchema } from '../../../common/schemas'; +import { addExceptionList, updateExceptionList } from '../api'; import { AddExceptionList, PersistHookProps } from '../types'; interface PersistReturnExceptionList { @@ -33,6 +34,8 @@ export const usePersistExceptionList = ({ const [exceptionList, setExceptionList] = useState(null); const [isSaved, setIsSaved] = useState(false); const [isLoading, setIsLoading] = useState(false); + const isUpdateExceptionList = (item: unknown): item is UpdateExceptionListSchema => + Boolean(item && (item as UpdateExceptionListSchema).id != null); useEffect(() => { let isSubscribed = true; @@ -43,7 +46,11 @@ export const usePersistExceptionList = ({ if (exceptionList != null) { try { setIsLoading(true); - await persistExceptionList({ http, list: exceptionList, signal: abortCtrl.signal }); + if (isUpdateExceptionList(exceptionList)) { + await updateExceptionList({ http, list: exceptionList, signal: abortCtrl.signal }); + } else { + await addExceptionList({ http, list: exceptionList, signal: abortCtrl.signal }); + } if (isSubscribed) { setIsSaved(true); } diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.tsx index edf65839c07cfd..1e0f7e58a0f4cf 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.tsx @@ -15,8 +15,6 @@ import { ApiCallByIdProps } from '../types'; import { ExceptionsApi, useApi } from './use_api'; -jest.mock('../api'); - const mockKibanaHttpService = createKibanaCoreStartMock().http; describe('useApi', () => { diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx index eeb3ac63ee3181..ae93ad75781c73 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx @@ -9,19 +9,24 @@ import { act, renderHook } from '@testing-library/react-hooks'; import * as api from '../api'; import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; -import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; +import { getFoundExceptionListItemSchemaMock } from '../../../common/schemas/response/found_exception_list_item_schema.mock'; import { ExceptionListItemSchema } from '../../../common/schemas'; import { ExceptionList, UseExceptionListProps, UseExceptionListSuccess } from '../types'; import { ReturnExceptionListAndItems, useExceptionList } from './use_exception_list'; -jest.mock('../api'); - const mockKibanaHttpService = createKibanaCoreStartMock().http; describe('useExceptionList', () => { const onErrorMock = jest.fn(); + beforeEach(() => { + jest.spyOn(api, 'fetchExceptionListById').mockResolvedValue(getExceptionListSchemaMock()); + jest + .spyOn(api, 'fetchExceptionListItemsByListId') + .mockResolvedValue(getFoundExceptionListItemSchemaMock()); + }); + afterEach(() => { onErrorMock.mockClear(); jest.clearAllMocks(); @@ -34,9 +39,15 @@ describe('useExceptionList', () => { ReturnExceptionListAndItems >(() => useExceptionList({ + filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, }) ); await waitForNextUpdate(); @@ -63,10 +74,16 @@ describe('useExceptionList', () => { ReturnExceptionListAndItems >(() => useExceptionList({ + filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, }) ); await waitForNextUpdate(); @@ -76,14 +93,12 @@ describe('useExceptionList', () => { { ...getExceptionListSchemaMock(), totalItems: 1 }, ]; - const expectedListItemsResult: ExceptionListItemSchema[] = [ - { ...getExceptionListItemSchemaMock() }, - ]; - + const expectedListItemsResult: ExceptionListItemSchema[] = getFoundExceptionListItemSchemaMock() + .data; const expectedResult: UseExceptionListSuccess = { exceptions: expectedListItemsResult, lists: expectedListResult, - pagination: { page: 1, perPage: 20, total: 1 }, + pagination: { page: 1, perPage: 1, total: 1 }, }; expect(result.current).toEqual([ @@ -92,7 +107,7 @@ describe('useExceptionList', () => { expectedListItemsResult, { page: 1, - perPage: 20, + perPage: 1, total: 1, }, result.current[4], @@ -104,6 +119,7 @@ describe('useExceptionList', () => { test('fetch a new exception list and its items', async () => { const spyOnfetchExceptionListById = jest.spyOn(api, 'fetchExceptionListById'); const spyOnfetchExceptionListItemsByListId = jest.spyOn(api, 'fetchExceptionListItemsByListId'); + const onSuccessMock = jest.fn(); await act(async () => { const { rerender, waitForNextUpdate } = renderHook< UseExceptionListProps, @@ -113,18 +129,31 @@ describe('useExceptionList', () => { useExceptionList({ filterOptions, http, lists, onError, onSuccess, pagination }), { initialProps: { + filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, - onSuccess: jest.fn(), + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, }, } ); await waitForNextUpdate(); rerender({ + filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, lists: [{ id: 'newListId', namespaceType: 'single' }], onError: onErrorMock, + onSuccess: onSuccessMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, }); await waitForNextUpdate(); @@ -142,9 +171,15 @@ describe('useExceptionList', () => { ReturnExceptionListAndItems >(() => useExceptionList({ + filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, }) ); await waitForNextUpdate(); @@ -173,9 +208,15 @@ describe('useExceptionList', () => { const { waitForNextUpdate } = renderHook( () => useExceptionList({ + filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, }) ); await waitForNextUpdate(); @@ -195,9 +236,15 @@ describe('useExceptionList', () => { const { waitForNextUpdate } = renderHook( () => useExceptionList({ + filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, }) ); await waitForNextUpdate(); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx index 9595cb7b7558ec..f0e3c3c28ad79e 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx @@ -137,7 +137,9 @@ export const useExceptionList = ({ perPage: 20, total: 0, }); - onError(error); + if (onError != null) { + onError(error); + } } } }; diff --git a/x-pack/plugins/lists/public/exceptions/mock.ts b/x-pack/plugins/lists/public/exceptions/mock.ts deleted file mode 100644 index fd06dac65c6fb5..00000000000000 --- a/x-pack/plugins/lists/public/exceptions/mock.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { - CreateExceptionListItemSchemaPartial, - CreateExceptionListSchemaPartial, -} from '../../common/schemas'; - -export const mockNewExceptionList: CreateExceptionListSchemaPartial = { - _tags: ['endpoint', 'process', 'malware', 'os:linux'], - description: 'This is a sample endpoint type exception', - list_id: 'endpoint_list', - name: 'Sample Endpoint Exception List', - tags: ['user added string for a tag', 'malware'], - type: 'endpoint', -}; - -export const mockNewExceptionItem: CreateExceptionListItemSchemaPartial = { - _tags: ['endpoint', 'process', 'malware', 'os:linux'], - description: 'This is a sample endpoint type exception', - entries: [ - { - field: 'actingProcess.file.signer', - match: 'Elastic, N.V.', - match_any: undefined, - operator: 'included', - }, - { - field: 'event.category', - match: undefined, - match_any: ['process', 'malware'], - operator: 'included', - }, - ], - item_id: 'endpoint_list_item', - list_id: 'endpoint_list', - name: 'Sample Endpoint Exception List', - tags: ['user added string for a tag', 'malware'], - type: 'simple', -}; diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index 013788cddc0760..658d2dbad06660 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -5,11 +5,16 @@ */ import { - CreateExceptionListItemSchemaPartial, - CreateExceptionListSchemaPartial, + CreateExceptionListItemSchema, + CreateExceptionListSchema, ExceptionListItemSchema, ExceptionListSchema, NamespaceType, + Page, + PerPage, + TotalOrUndefined, + UpdateExceptionListItemSchema, + UpdateExceptionListSchema, } from '../../common/schemas'; import { HttpStart } from '../../../../../src/core/public'; @@ -19,14 +24,14 @@ export interface FilterExceptionsOptions { } export interface Pagination { - page: number; - perPage: number; - total: number; + page: Page; + perPage: PerPage; + total: TotalOrUndefined; } -export type AddExceptionList = ExceptionListSchema | CreateExceptionListSchemaPartial; +export type AddExceptionList = UpdateExceptionListSchema | CreateExceptionListSchema; -export type AddExceptionListItem = CreateExceptionListItemSchemaPartial | ExceptionListItemSchema; +export type AddExceptionListItem = CreateExceptionListItemSchema | UpdateExceptionListItemSchema; export interface PersistHookProps { http: HttpStart; @@ -46,7 +51,7 @@ export interface UseExceptionListSuccess { export interface UseExceptionListProps { http: HttpStart; lists: ExceptionIdentifiers[]; - onError: (arg: Error) => void; + onError?: (arg: string[]) => void; filterOptions?: FilterExceptionsOptions; pagination?: Pagination; onSuccess?: (arg: UseExceptionListSuccess) => void; @@ -63,7 +68,7 @@ export interface ApiCallByListIdProps { listId: string; namespaceType: NamespaceType; filterOptions?: FilterExceptionsOptions; - pagination?: Pagination; + pagination: Partial; signal: AbortSignal; } @@ -77,18 +82,30 @@ export interface ApiCallByIdProps { export interface ApiCallMemoProps { id: string; namespaceType: NamespaceType; - onError: (arg: Error) => void; + onError: (arg: string[]) => void; onSuccess: () => void; } export interface AddExceptionListProps { http: HttpStart; - list: AddExceptionList; + list: CreateExceptionListSchema; signal: AbortSignal; } export interface AddExceptionListItemProps { http: HttpStart; - listItem: AddExceptionListItem; + listItem: CreateExceptionListItemSchema; + signal: AbortSignal; +} + +export interface UpdateExceptionListProps { + http: HttpStart; + list: UpdateExceptionListSchema; + signal: AbortSignal; +} + +export interface UpdateExceptionListItemProps { + http: HttpStart; + listItem: UpdateExceptionListItemSchema; signal: AbortSignal; } diff --git a/x-pack/plugins/lists/public/index.tsx b/x-pack/plugins/lists/public/index.tsx index 1e25275a0d38ba..71187273c731cd 100644 --- a/x-pack/plugins/lists/public/index.tsx +++ b/x-pack/plugins/lists/public/index.tsx @@ -8,5 +8,9 @@ export { useApi } from './exceptions/hooks/use_api'; export { usePersistExceptionItem } from './exceptions/hooks/persist_exception_item'; export { usePersistExceptionList } from './exceptions/hooks/persist_exception_list'; export { useExceptionList } from './exceptions/hooks/use_exception_list'; -export { ExceptionList, ExceptionIdentifiers } from './exceptions/types'; -export { mockNewExceptionItem, mockNewExceptionList } from './exceptions/mock'; +export { + ExceptionList, + ExceptionIdentifiers, + Pagination, + UseExceptionListSuccess, +} from './exceptions/types'; diff --git a/x-pack/plugins/lists/server/saved_objects/exception_list.ts b/x-pack/plugins/lists/server/saved_objects/exception_list.ts index 8fb618c01213ca..10f9b1f4383f5f 100644 --- a/x-pack/plugins/lists/server/saved_objects/exception_list.ts +++ b/x-pack/plugins/lists/server/saved_objects/exception_list.ts @@ -81,16 +81,42 @@ export const exceptionListItemMapping: SavedObjectsType['mappings'] = { }, entries: { properties: { + entries: { + properties: { + field: { + type: 'keyword', + }, + operator: { + type: 'keyword', + }, + type: { + type: 'keyword', + }, + value: { + fields: { + text: { + type: 'text', + }, + }, + type: 'keyword', + }, + }, + }, field: { type: 'keyword', }, - match: { + operator: { type: 'keyword', }, - match_any: { + type: { type: 'keyword', }, - operator: { + value: { + fields: { + text: { + type: 'text', + }, + }, type: 'keyword', }, }, diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json index 2b97f37a7fa6b8..8663be5d649e5d 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json @@ -9,13 +9,14 @@ "entries": [ { "field": "actingProcess.file.signer", - "operator": "included", - "match": "Elastic, N.V." + "operator": "excluded", + "type": "exists" }, { - "field": "event.category", + "field": "host.name", "operator": "included", - "match_any": ["process", "malware"] + "type": "match_any", + "value": ["some host", "another host"] } ] } diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_agnostic.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_agnostic.json index db0b11480b81a8..9cda9c12d6b30a 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_agnostic.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_agnostic.json @@ -11,12 +11,26 @@ { "field": "actingProcess.file.signer", "operator": "included", - "match": "Elastic, N.V." + "type": "match", + "value": "Elastic, N.V." }, { - "field": "event.category", - "operator": "included", - "match_any": ["process", "malware"] + "field": "file.signature", + "type": "nested", + "entries": [ + { + "field": "signer", + "type": "match", + "operator": "included", + "value": "Evil" + }, + { + "field": "trusted", + "type": "match", + "operator": "included", + "value": "true" + } + ] } ] } diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json index f1a0dd0fa9c91b..d63adc84a361db 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json @@ -10,12 +10,26 @@ { "field": "actingProcess.file.signer", "operator": "included", - "match": "Elastic, N.V." + "type": "match", + "value": "Elastic, N.V." }, { - "field": "event.category", - "operator": "included", - "match_any": ["process", "malware"] + "field": "file.signature", + "type": "nested", + "entries": [ + { + "field": "signer", + "type": "match", + "operator": "included", + "value": "Evil" + }, + { + "field": "trusted", + "type": "match", + "operator": "included", + "value": "true" + } + ] } ] } diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_detection_auto_id.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_detection_auto_id.json index ad3fef7e143646..833f6c023c5d97 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_detection_auto_id.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_detection_auto_id.json @@ -8,19 +8,33 @@ "comments": [{ "comment": "This is a short little comment." }], "entries": [ { - "field": "host.name", - "operator": "included", - "match": "sampleHostName" + "field": "actingProcess.file.signer", + "operator": "excluded", + "type": "exists" }, { - "field": "event.category", + "field": "host.name", "operator": "included", - "match_any": ["process", "malware"] + "type": "match_any", + "value": ["some host", "another host"] }, { - "field": "event.action", - "operator": "included", - "match": "user-password-change" + "field": "file.signature", + "type": "nested", + "entries": [ + { + "field": "signer", + "type": "match", + "operator": "included", + "value": "Evil" + }, + { + "field": "trusted", + "type": "match", + "operator": "included", + "value": "true" + } + ] } ] } diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_agnostic.json b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_agnostic.json index 72ddd15ebee47a..90d5e0846e53a2 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_agnostic.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_agnostic.json @@ -10,7 +10,8 @@ { "field": "event.category", "operator": "included", - "match_any": ["process", "malware"] + "type": "match_any", + "value": ["process", "malware"] } ] } diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json index 27f020c43d1bf3..33c9303c7b5231 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json @@ -17,7 +17,8 @@ { "field": "event.category", "operator": "included", - "match_any": ["process", "malware"] + "type": "match_any", + "value": ["process", "malware"] } ] } diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index ad99780a7d32fb..edb395633827f6 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -227,3 +227,9 @@ export enum INITIAL_LOCATION { FIXED_LOCATION = 'FIXED_LOCATION', BROWSER_LOCATION = 'BROWSER_LOCATION', } + +export enum LAYER_WIZARD_CATEGORY { + ELASTICSEARCH = 'ELASTICSEARCH', + REFERENCE = 'REFERENCE', + SOLUTIONS = 'SOLUTIONS', +} diff --git a/x-pack/plugins/maps/common/map_saved_object_type.ts b/x-pack/plugins/maps/common/map_saved_object_type.ts index e5b4876186fd84..e195d9e4538f08 100644 --- a/x-pack/plugins/maps/common/map_saved_object_type.ts +++ b/x-pack/plugins/maps/common/map_saved_object_type.ts @@ -13,10 +13,6 @@ export type MapSavedObjectAttributes = { mapStateJSON?: string; layerListJSON?: string; uiStateJSON?: string; - bounds?: { - type?: string; - coordinates?: []; - }; }; export type MapSavedObject = SavedObject; diff --git a/x-pack/plugins/maps/common/migrations/remove_bounds.test.ts b/x-pack/plugins/maps/common/migrations/remove_bounds.test.ts new file mode 100644 index 00000000000000..ac1ffd0d8c8900 --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/remove_bounds.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { removeBoundsFromSavedObject } from './remove_bounds'; + +describe('removeBoundsFromSavedObject', () => { + test('Remove when present', () => { + const attributes = { + title: 'my map', + bounds: { + type: 'polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [0, 1], + [0, 0], + ], + ], + }, + }; + expect(removeBoundsFromSavedObject({ attributes })).toEqual({ + title: 'my map', + }); + }); + + test('No-op when absent', () => { + const attributes = { + title: 'my map', + }; + expect(removeBoundsFromSavedObject({ attributes })).toEqual({ + title: 'my map', + }); + }); +}); diff --git a/x-pack/plugins/maps/common/migrations/remove_bounds.ts b/x-pack/plugins/maps/common/migrations/remove_bounds.ts new file mode 100644 index 00000000000000..05c8d965c2ace3 --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/remove_bounds.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MapSavedObjectAttributes } from '../map_saved_object_type'; + +export function removeBoundsFromSavedObject({ + attributes, +}: { + attributes: MapSavedObjectAttributes; +}): MapSavedObjectAttributes { + const newAttributes = { ...attributes }; + // @ts-expect-error + // This removes an unused parameter from pre 7.8=< saved objects + delete newAttributes.bounds; + return { ...newAttributes }; +} diff --git a/x-pack/plugins/maps/common/migrations/scaling_type.ts b/x-pack/plugins/maps/common/migrations/scaling_type.ts index 98a06a764f4ec4..d77ca03ea50b63 100644 --- a/x-pack/plugins/maps/common/migrations/scaling_type.ts +++ b/x-pack/plugins/maps/common/migrations/scaling_type.ts @@ -30,7 +30,8 @@ export function migrateUseTopHitsToScalingType({ sourceDescriptor.scalingType = _.get(layerDescriptor, 'sourceDescriptor.useTopHits', false) ? SCALING_TYPES.TOP_HITS : SCALING_TYPES.LIMIT; - // @ts-ignore useTopHits no longer in type definition but that does not mean its not in live data + // @ts-expect-error + // useTopHits no longer in type definition but that does not mean its not in live data // hence the entire point of this method delete sourceDescriptor.useTopHits; } diff --git a/x-pack/plugins/maps/public/angular/services/saved_gis_map.js b/x-pack/plugins/maps/public/angular/services/saved_gis_map.js index ddb58b610f5f1a..a0beaa768888a8 100644 --- a/x-pack/plugins/maps/public/angular/services/saved_gis_map.js +++ b/x-pack/plugins/maps/public/angular/services/saved_gis_map.js @@ -11,16 +11,12 @@ import { getMapZoom, getMapCenter, getLayerListRaw, - getMapExtent, getRefreshConfig, getQuery, getFilters, getMapSettings, } from '../../selectors/map_selectors'; import { getIsLayerTOCOpen, getOpenTOCDetails } from '../../selectors/ui_selectors'; - -import { formatEnvelopeAsPolygon } from '../../elasticsearch_geo_utils'; - import { copyPersistentState } from '../../reducers/util'; import { extractReferences, injectReferences } from '../../../common/migrations/references'; import { MAP_SAVED_OBJECT_TYPE } from '../../../common/constants'; @@ -38,9 +34,6 @@ export function createSavedGisMapClass(services) { mapStateJSON: 'text', layerListJSON: 'text', uiStateJSON: 'text', - bounds: { - type: 'object', - }, }; static fieldOrder = ['title', 'description']; static searchSource = false; @@ -106,8 +99,6 @@ export function createSavedGisMapClass(services) { isLayerTOCOpen: getIsLayerTOCOpen(state), openTOCDetails: getOpenTOCDetails(state), }); - - this.bounds = formatEnvelopeAsPolygon(getMapExtent(state)); } } return SavedGisMap; diff --git a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts index 2bdeb6446cf288..a255ffb00e312b 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts @@ -7,6 +7,7 @@ import { ReactElement } from 'react'; import { LayerDescriptor } from '../../../common/descriptor_types'; +import { LAYER_WIZARD_CATEGORY } from '../../../common/constants'; export type RenderWizardArguments = { previewLayers: (layerDescriptors: LayerDescriptor[], isIndexingSource?: boolean) => void; @@ -20,6 +21,7 @@ export type RenderWizardArguments = { }; export type LayerWizard = { + categories: LAYER_WIZARD_CATEGORY[]; checkVisibility?: () => Promise; description: string; icon: string; diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_wizard.tsx index db97c08596e065..ddb07a9facee7b 100644 --- a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_wizard.tsx @@ -6,12 +6,14 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { LAYER_WIZARD_CATEGORY } from '../../../../../common/constants'; import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; import { ObservabilityLayerTemplate } from './observability_layer_template'; import { APM_INDEX_PATTERN_ID } from './create_layer_descriptor'; import { getIndexPatternService } from '../../../../kibana_services'; export const ObservabilityLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH, LAYER_WIZARD_CATEGORY.SOLUTIONS], checkVisibility: async () => { try { await getIndexPatternService().get(APM_INDEX_PATTERN_ID); diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_wizard.tsx index cece00fa373503..f51aa5b40aa80b 100644 --- a/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_wizard.tsx @@ -6,11 +6,13 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { LAYER_WIZARD_CATEGORY } from '../../../../../common/constants'; import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; import { getSecurityIndexPatterns } from './security_index_pattern_utils'; import { SecurityLayerTemplate } from './security_layer_template'; export const SecurityLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH, LAYER_WIZARD_CATEGORY.SOLUTIONS], checkVisibility: async () => { const indexPatterns = await getSecurityIndexPatterns(); return indexPatterns.length > 0; diff --git a/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx index 3f4ec0d3f12685..0a224f75b981d6 100644 --- a/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx @@ -22,6 +22,7 @@ import { GeojsonFileSource } from './geojson_file_source'; import { VectorLayer } from '../../layers/vector_layer/vector_layer'; export const uploadLayerWizardConfig: LayerWizard = { + categories: [], description: i18n.translate('xpack.maps.source.geojsonFileDescription', { defaultMessage: 'Index GeoJSON data in Elasticsearch', }), diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx index 7eec84ef5bb2e1..c53a7a4facb0c3 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx @@ -13,8 +13,10 @@ import { EMSFileSource, sourceTitle } from './ems_file_source'; // @ts-ignore import { getIsEmsEnabled } from '../../../kibana_services'; import { EMSFileSourceDescriptor } from '../../../../common/descriptor_types'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const emsBoundariesLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: () => { return getIsEmsEnabled(); }, diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx index 60e67b1ae70534..49d262cbad1a10 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx @@ -13,8 +13,10 @@ import { VectorTileLayer } from '../../layers/vector_tile_layer/vector_tile_laye // @ts-ignore import { TileServiceSelect } from './tile_service_select'; import { getIsEmsEnabled } from '../../../kibana_services'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const emsBaseMapLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: () => { return getIsEmsEnabled(); }, diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx index b9d5faa8e18f1b..715c16b22dc51a 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx @@ -23,6 +23,7 @@ import { COUNT_PROP_NAME, COLOR_MAP_TYPE, FIELD_ORIGIN, + LAYER_WIZARD_CATEGORY, RENDER_AS, VECTOR_STYLES, STYLE_TYPE, @@ -30,6 +31,7 @@ import { import { COLOR_GRADIENTS } from '../../styles/color_utils'; export const clustersLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esGridClustersDescription', { defaultMessage: 'Geospatial data grouped in grids with metrics for each gridded cell', }), diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx index 79252c7febf8c9..92a0f1006ea439 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx @@ -14,9 +14,10 @@ import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_re // @ts-ignore import { HeatmapLayer } from '../../layers/heatmap_layer/heatmap_layer'; import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types'; -import { RENDER_AS } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, RENDER_AS } from '../../../../common/constants'; export const heatmapLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esGridHeatmapDescription', { defaultMessage: 'Geospatial data grouped in grids to show density', }), diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx index 5169af9bdddf2f..ae7414b827c8d8 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx @@ -14,6 +14,7 @@ import { VectorStyle } from '../../styles/vector/vector_style'; import { FIELD_ORIGIN, COUNT_PROP_NAME, + LAYER_WIZARD_CATEGORY, VECTOR_STYLES, STYLE_TYPE, } from '../../../../common/constants'; @@ -24,6 +25,7 @@ import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_re import { ColorDynamicOptions, SizeDynamicOptions } from '../../../../common/descriptor_types'; export const point2PointLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.pewPewDescription', { defaultMessage: 'Aggregated data paths between the source and destination', }), diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx index 888de2e7297cba..4598b1467229d4 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx @@ -13,7 +13,7 @@ import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_re import { ESSearchSource, sourceTitle } from './es_search_source'; import { BlendedVectorLayer } from '../../layers/blended_vector_layer/blended_vector_layer'; import { VectorLayer } from '../../layers/vector_layer/vector_layer'; -import { SCALING_TYPES } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, SCALING_TYPES } from '../../../../common/constants'; export function createDefaultLayerDescriptor(sourceConfig: unknown, mapColors: string[]) { const sourceDescriptor = ESSearchSource.createDescriptor(sourceConfig); @@ -24,6 +24,7 @@ export function createDefaultLayerDescriptor(sourceConfig: unknown, mapColors: s } export const esDocumentsLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esSearchDescription', { defaultMessage: 'Vector data from a Kibana index pattern', }), diff --git a/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx index ca78aaefe404f7..c8a1c346646e01 100644 --- a/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx @@ -13,8 +13,10 @@ import { VectorLayer } from '../../layers/vector_layer/vector_layer'; // @ts-ignore import { CreateSourceEditor } from './create_source_editor'; import { getKibanaRegionList } from '../../../meta'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const kibanaRegionMapLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: async () => { const regions = getKibanaRegionList(); return regions.length > 0; diff --git a/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx index 84d2e5e74fa9a1..9f63372a785119 100644 --- a/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx @@ -13,8 +13,10 @@ import { CreateSourceEditor } from './create_source_editor'; import { KibanaTilemapSource, sourceTitle } from './kibana_tilemap_source'; import { TileLayer } from '../../layers/tile_layer/tile_layer'; import { getKibanaTileMap } from '../../../meta'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const kibanaBasemapLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: async () => { const tilemap = getKibanaTileMap(); // @ts-ignore diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx index c29302a2058b21..067c7f5a47ca35 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx @@ -13,8 +13,10 @@ import { import { MVTSingleLayerVectorSource, sourceTitle } from './mvt_single_layer_vector_source'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; import { TiledVectorLayer } from '../../layers/tiled_vector_layer/tiled_vector_layer'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const mvtVectorSourceWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.mvtVectorSourceWizard', { defaultMessage: 'Vector source wizard', }), diff --git a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx index 62eeef234f4141..b3950baf8dbeb0 100644 --- a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx @@ -12,8 +12,10 @@ import { WMSCreateSourceEditor } from './wms_create_source_editor'; import { sourceTitle, WMSSource } from './wms_source'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; import { TileLayer } from '../../layers/tile_layer/tile_layer'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const wmsLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.wmsDescription', { defaultMessage: 'Maps from OGC Standard WMS', }), diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx index b99b17c1d22d47..48c526855d3a4f 100644 --- a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx @@ -10,8 +10,10 @@ import { XYZTMSEditor, XYZTMSSourceConfig } from './xyz_tms_editor'; import { XYZTMSSource, sourceTitle } from './xyz_tms_source'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; import { TileLayer } from '../../layers/tile_layer/tile_layer'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const tmsLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.ems_xyzDescription', { defaultMessage: 'Tile map service configured in interface', }), diff --git a/x-pack/plugins/maps/public/connected_components/_index.scss b/x-pack/plugins/maps/public/connected_components/_index.scss index 6de2a51590700a..bd8070e8c36fdb 100644 --- a/x-pack/plugins/maps/public/connected_components/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/_index.scss @@ -1,5 +1,4 @@ @import 'gis_map/gis_map'; -@import 'add_layer_panel/index'; @import 'layer_panel/index'; @import 'widget_overlay/index'; @import 'toolbar_overlay/index'; diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/_index.scss b/x-pack/plugins/maps/public/connected_components/add_layer_panel/_index.scss deleted file mode 100644 index 4e60b8d4b7c4b0..00000000000000 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/_index.scss +++ /dev/null @@ -1,12 +0,0 @@ -.mapLayerAddpanel__card { - // EUITODO: Fix horizontal layout so it works with any size icon - .euiCard__content { - // sass-lint:disable-block no-important - padding-top: 0 !important; - } - - .euiCard__top + .euiCard__content { - // sass-lint:disable-block no-important - padding-top: 2px !important; - } -} diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap new file mode 100644 index 00000000000000..ef11f9958d8db6 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LayerWizardSelect Should render layer select after layer wizards are loaded 1`] = ` + + + + + + + Elasticsearch + + + Solutions + + + + + + + + + + } + onClick={[Function]} + title="wizard 2" + /> + + + +`; + +exports[`LayerWizardSelect Should render loading screen before layer wizards are loaded 1`] = ` +
+ + } + layout="horizontal" + title="" + /> +
+`; diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.test.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.test.tsx new file mode 100644 index 00000000000000..e802c5259e5eda --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('../../../classes/layers/layer_wizard_registry', () => ({})); + +import React from 'react'; +import { shallow } from 'enzyme'; +import { LayerWizardSelect } from './layer_wizard_select'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; + +const defaultProps = { + onSelect: () => {}, +}; + +describe('LayerWizardSelect', () => { + beforeAll(() => { + require('../../../classes/layers/layer_wizard_registry').getLayerWizards = async () => { + return [ + { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], + description: 'mock wizard without icon', + renderWizard: () => { + return
; + }, + title: 'wizard 1', + }, + { + categories: [LAYER_WIZARD_CATEGORY.SOLUTIONS], + description: 'mock wizard with icon', + icon: 'logoObservability', + renderWizard: () => { + return
; + }, + title: 'wizard 2', + }, + ]; + }; + }); + + test('Should render layer select after layer wizards are loaded', async () => { + const component = shallow(); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('Should render loading screen before layer wizards are loaded', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx index b0c50133ceabb8..f0195bc5dee2f2 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx @@ -5,26 +5,63 @@ */ import _ from 'lodash'; -import React, { Component, Fragment } from 'react'; -import { EuiSpacer, EuiCard, EuiIcon } from '@elastic/eui'; -import { EuiLoadingContent } from '@elastic/eui'; +import React, { Component } from 'react'; +import { + EuiCard, + EuiIcon, + EuiFlexGrid, + EuiFlexItem, + EuiLoadingContent, + EuiFacetGroup, + EuiFacetButton, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { getLayerWizards, LayerWizard } from '../../../classes/layers/layer_wizard_registry'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; interface Props { onSelect: (layerWizard: LayerWizard) => void; } interface State { - layerWizards: LayerWizard[]; + activeCategories: LAYER_WIZARD_CATEGORY[]; hasLoadedWizards: boolean; + layerWizards: LayerWizard[]; + selectedCategory: LAYER_WIZARD_CATEGORY | null; +} + +function getCategoryLabel(category: LAYER_WIZARD_CATEGORY): string { + if (category === LAYER_WIZARD_CATEGORY.ELASTICSEARCH) { + return i18n.translate('xpack.maps.layerWizardSelect.elasticsearchCategoryLabel', { + defaultMessage: 'Elasticsearch', + }); + } + + if (category === LAYER_WIZARD_CATEGORY.REFERENCE) { + return i18n.translate('xpack.maps.layerWizardSelect.referenceCategoryLabel', { + defaultMessage: 'Reference', + }); + } + + if (category === LAYER_WIZARD_CATEGORY.SOLUTIONS) { + return i18n.translate('xpack.maps.layerWizardSelect.solutionsCategoryLabel', { + defaultMessage: 'Solutions', + }); + } + + throw new Error(`Unexpected category: ${category}`); } export class LayerWizardSelect extends Component { private _isMounted: boolean = false; state = { - layerWizards: [], + activeCategories: [], hasLoadedWizards: false, + layerWizards: [], + selectedCategory: null, }; componentDidMount() { @@ -38,9 +75,57 @@ export class LayerWizardSelect extends Component { async _loadLayerWizards() { const layerWizards = await getLayerWizards(); + const activeCategories: LAYER_WIZARD_CATEGORY[] = []; + layerWizards.forEach((layerWizard: LayerWizard) => { + layerWizard.categories.forEach((category: LAYER_WIZARD_CATEGORY) => { + if (!activeCategories.includes(category)) { + activeCategories.push(category); + } + }); + }); + if (this._isMounted) { - this.setState({ layerWizards, hasLoadedWizards: true }); + this.setState({ + activeCategories, + layerWizards, + hasLoadedWizards: true, + }); + } + } + + _filterByCategory(category: LAYER_WIZARD_CATEGORY | null) { + this.setState({ selectedCategory: category }); + } + + _renderCategoryFacets() { + if (this.state.activeCategories.length === 0) { + return null; } + + const facets = this.state.activeCategories.map((category: LAYER_WIZARD_CATEGORY) => { + return ( + this._filterByCategory(category)} + > + {getCategoryLabel(category)} + + ); + }); + + return ( + + this._filterByCategory(null)} + > + + + {facets} + + ); } render() { @@ -51,27 +136,41 @@ export class LayerWizardSelect extends Component {
); } - return this.state.layerWizards.map((layerWizard: LayerWizard) => { - const icon = layerWizard.icon ? : undefined; - const onClick = () => { - this.props.onSelect(layerWizard); - }; + const wizardCards = this.state.layerWizards + .filter((layerWizard: LayerWizard) => { + return this.state.selectedCategory + ? layerWizard.categories.includes(this.state.selectedCategory!) + : true; + }) + .map((layerWizard: LayerWizard) => { + const icon = layerWizard.icon ? : undefined; - return ( - - - - - ); - }); + const onClick = () => { + this.props.onSelect(layerWizard); + }; + + return ( + + + + ); + }); + + return ( + <> + {this._renderCategoryFacets()} + + + {wizardCards} + + + ); } } diff --git a/x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss b/x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss index 85168d970c6dec..2180573ef4583d 100644 --- a/x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss +++ b/x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss @@ -9,11 +9,11 @@ overflow: hidden; > * { - width: $euiSizeXXL * 11; + width: $euiSizeXXL * 12; } &-isVisible { - width: $euiSizeXXL * 11; + width: $euiSizeXXL * 12; transition: width $euiAnimSpeedNormal $euiAnimSlightResistance; } } diff --git a/x-pack/plugins/maps/server/maps_telemetry/test_resources/sample_map_saved_objects.json b/x-pack/plugins/maps/server/maps_telemetry/test_resources/sample_map_saved_objects.json index 5bfe8ae38cac97..017f9e69ffe466 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/test_resources/sample_map_saved_objects.json +++ b/x-pack/plugins/maps/server/maps_telemetry/test_resources/sample_map_saved_objects.json @@ -7,34 +7,7 @@ "description": "", "mapStateJSON": "{\"zoom\":4.82,\"center\":{\"lon\":11.41545,\"lat\":42.0865},\"timeFilters\":{\"from\":\"now-15w\",\"to\":\"now\"},\"refreshConfig\":{\"isPaused\":false,\"interval\":0},\"query\":{\"language\":\"lucene\",\"query\":\"\"}}", "layerListJSON": "[{\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"road_map\"},\"id\":\"csq5v\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.65,\"visible\":true,\"style\":{\"type\":\"TILE\",\"properties\":{}},\"type\":\"TILE\"},{\"sourceDescriptor\":{\"type\":\"EMS_FILE\",\"id\":\"italy_provinces\"},\"id\":\"0oye8\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#0c1f70\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}}}},\"type\":\"VECTOR\"},{\"sourceDescriptor\":{\"type\":\"ES_GEO_GRID\",\"id\":\"053fe296-f5ae-4cb0-9e73-a5752cb9ba74\",\"indexPatternId\":\"d3d7af60-4c81-11e8-b3d7-01146121b73d\",\"geoField\":\"DestLocation\",\"requestType\":\"point\",\"resolution\":\"COARSE\"},\"id\":\"1gx22\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"Count\",\"name\":\"doc_count\",\"origin\":\"source\"},\"color\":\"Greens\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"Count\",\"name\":\"doc_count\",\"origin\":\"source\"},\"minSize\":4,\"maxSize\":32}}}},\"type\":\"VECTOR\"}]", - "uiStateJSON": "{}", - "bounds": { - "type": "polygon", - "coordinates": [ - [ - [ - -5.29778, - 51.54155 - ], - [ - -5.29778, - 30.98066 - ], - [ - 28.12868, - 30.98066 - ], - [ - 28.12868, - 51.54155 - ], - [ - -5.29778, - 51.54155 - ] - ] - ] - } + "uiStateJSON": "{}" }, "references": [ ], @@ -49,34 +22,7 @@ "description": "", "mapStateJSON": "{\"zoom\":3.43,\"center\":{\"lon\":-16.30411,\"lat\":42.88411},\"timeFilters\":{\"from\":\"now-15w\",\"to\":\"now\"},\"refreshConfig\":{\"isPaused\":false,\"interval\":0},\"query\":{\"query\":\"\",\"language\":\"lucene\"}}", "layerListJSON": "[{\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"road_map\"},\"id\":\"csq5v\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.65,\"visible\":true,\"style\":{\"type\":\"TILE\",\"properties\":{}},\"type\":\"TILE\"},{\"sourceDescriptor\":{\"type\":\"EMS_FILE\",\"id\":\"france_departments\"},\"id\":\"65xbw\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.25,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#19c1e6\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}}}},\"type\":\"VECTOR\"},{\"sourceDescriptor\":{\"id\":\"240125db-e612-4001-b853-50107e55d984\",\"type\":\"ES_SEARCH\",\"indexPatternId\":\"ff959d40-b880-11e8-a6d9-e546fe2bba5f\",\"geoField\":\"geoip.location\",\"limit\":2048,\"filterByMapBounds\":true,\"tooltipProperties\":[]},\"id\":\"mdae9\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#1ce619\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}}}},\"type\":\"VECTOR\"}]", - "uiStateJSON": "{}", - "bounds": { - "type": "polygon", - "coordinates": [ - [ - [ - -59.97005, - 63.9123 - ], - [ - -59.97005, - 11.25616 - ], - [ - 27.36184, - 11.25616 - ], - [ - 27.36184, - 63.9123 - ], - [ - -59.97005, - 63.9123 - ] - ] - ] - } + "uiStateJSON": "{}" }, "references": [ ], @@ -91,34 +37,7 @@ "description": "", "mapStateJSON": "{\"zoom\":2.12,\"center\":{\"lon\":-88.67592,\"lat\":34.23257},\"timeFilters\":{\"from\":\"now-15m\",\"to\":\"now\",\"mode\":\"quick\"},\"refreshConfig\":{\"isPaused\":false,\"interval\":0},\"query\":{\"query\":\"\",\"language\":\"lucene\"}}", "layerListJSON": "[{\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"road_map\"},\"id\":\"csq5v\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.65,\"visible\":true,\"style\":{\"type\":\"TILE\",\"properties\":{}},\"type\":\"TILE\"},{\"sourceDescriptor\":{\"type\":\"EMS_FILE\",\"id\":\"canada_provinces\"},\"id\":\"kt086\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#60895e\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}}}},\"type\":\"VECTOR\"}]", - "uiStateJSON": "{}", - "bounds": { - "type": "polygon", - "coordinates": [ - [ - [ - 163.37506, - 77.35215 - ], - [ - 163.37506, - -46.80667 - ], - [ - 19.2731, - -46.80667 - ], - [ - 19.2731, - 77.35215 - ], - [ - 163.37506, - 77.35215 - ] - ] - ] - } + "uiStateJSON": "{}" }, "references": [ ], diff --git a/x-pack/plugins/maps/server/saved_objects/map.ts b/x-pack/plugins/maps/server/saved_objects/map.ts index 05f76c060ca94d..0fcadc5a972033 100644 --- a/x-pack/plugins/maps/server/saved_objects/map.ts +++ b/x-pack/plugins/maps/server/saved_objects/map.ts @@ -17,7 +17,6 @@ export const mapSavedObjects: SavedObjectsType = { description: { type: 'text' }, title: { type: 'text' }, version: { type: 'integer' }, - bounds: { type: 'geo_shape' }, mapStateJSON: { type: 'text' }, layerListJSON: { type: 'text' }, uiStateJSON: { type: 'text' }, diff --git a/x-pack/plugins/maps/server/saved_objects/migrations.js b/x-pack/plugins/maps/server/saved_objects/migrations.js index 5f9576740db298..5db21bb110dbb9 100644 --- a/x-pack/plugins/maps/server/saved_objects/migrations.js +++ b/x-pack/plugins/maps/server/saved_objects/migrations.js @@ -12,6 +12,7 @@ import { addFieldMetaOptions } from '../../common/migrations/add_field_meta_opti import { migrateSymbolStyleDescriptor } from '../../common/migrations/migrate_symbol_style_descriptor'; import { migrateUseTopHitsToScalingType } from '../../common/migrations/scaling_type'; import { migrateJoinAggKey } from '../../common/migrations/join_agg_key'; +import { removeBoundsFromSavedObject } from '../../common/migrations/remove_bounds'; export const migrations = { map: { @@ -61,6 +62,14 @@ export const migrations = { '7.8.0': (doc) => { const attributes = migrateJoinAggKey(doc); + return { + ...doc, + attributes, + }; + }, + '7.9.0': (doc) => { + const attributes = removeBoundsFromSavedObject(doc); + return { ...doc, attributes, diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 4b6ff8c64822b9..b871d857f7fded 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -35,6 +35,8 @@ const App: FC = ({ coreStart, deps }) => { }; const services = { appName: 'ML', + kibanaVersion: deps.kibanaVersion, + share: deps.share, data: deps.data, security: deps.security, licenseManagement: deps.licenseManagement, diff --git a/x-pack/plugins/ml/public/application/components/loading_indicator/index.js b/x-pack/plugins/ml/public/application/components/loading_indicator/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/loading_indicator/index.js rename to x-pack/plugins/ml/public/application/components/loading_indicator/index.ts diff --git a/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.js b/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.tsx similarity index 70% rename from x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.js rename to x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.tsx index 20f4fb86b5372c..364b23a27eaf7c 100644 --- a/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.js +++ b/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.tsx @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { FC } from 'react'; import { EuiLoadingChart, EuiSpacer } from '@elastic/eui'; -export function LoadingIndicator({ height, label }) { +export const LoadingIndicator: FC<{ height?: number; label?: string }> = ({ height, label }) => { height = height ? +height : 100; return (
-
{label}
+
{label}
)}
); -} -LoadingIndicator.propTypes = { - height: PropTypes.number, - label: PropTypes.string, }; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index c65d872212ad67..2a156b5716ad4d 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -12,13 +12,15 @@ import { } from '../../../../../../../src/plugins/kibana_react/public'; import { SecurityPluginSetup } from '../../../../../security/public'; import { LicenseManagementUIPluginSetup } from '../../../../../license_management/public'; +import { SharePluginStart } from '../../../../../../../src/plugins/share/public'; interface StartPlugins { data: DataPublicPluginStart; security?: SecurityPluginSetup; licenseManagement?: LicenseManagementUIPluginSetup; + share: SharePluginStart; } -export type StartServices = CoreStart & StartPlugins; +export type StartServices = CoreStart & StartPlugins & { kibanaVersion: string }; // eslint-disable-next-line react-hooks/rules-of-hooks export const useMlKibana = () => useKibana(); export type MlKibanaReactContextValue = KibanaReactContextValue; diff --git a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts index f8abd48ce85620..07d5a153664b75 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts @@ -14,6 +14,7 @@ export interface MlContextValue { currentSavedSearch: SavedSearchSavedObject | null; indexPatterns: IndexPatternsContract; kibanaConfig: any; // IUiSettingsClient; + kibanaVersion: string; } export type SavedSearchQuery = object; diff --git a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx new file mode 100644 index 00000000000000..cb11a33ccfd76a --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx @@ -0,0 +1,321 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useCallback, useMemo, useState, useEffect } from 'react'; +import { debounce } from 'lodash'; +import { + EuiFormRow, + EuiCheckboxGroup, + EuiInMemoryTableProps, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSpacer, + EuiButtonEmpty, + EuiButton, + EuiModalFooter, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiModalBody } from '@elastic/eui'; +import { EuiInMemoryTable } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useMlKibana } from '../contexts/kibana'; +import { SavedObjectDashboard } from '../../../../../../src/plugins/dashboard/public'; +import { + ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, + getDefaultPanelTitle, +} from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { useDashboardService } from '../services/dashboard_service'; +import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; +import { JobId } from '../../../common/types/anomaly_detection_jobs'; + +export interface DashboardItem { + id: string; + title: string; + description: string | undefined; + attributes: SavedObjectDashboard; +} + +export type EuiTableProps = EuiInMemoryTableProps; + +function getDefaultEmbeddablepaPanelConfig(jobIds: JobId[]) { + return { + type: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, + title: getDefaultPanelTitle(jobIds), + }; +} + +interface AddToDashboardControlProps { + jobIds: JobId[]; + viewBy: string; + limit: number; + onClose: (callback?: () => Promise) => void; +} + +/** + * Component for attaching anomaly swimlane embeddable to dashboards. + */ +export const AddToDashboardControl: FC = ({ + onClose, + jobIds, + viewBy, + limit, +}) => { + const { + notifications: { toasts }, + services: { + application: { navigateToUrl }, + }, + } = useMlKibana(); + + useEffect(() => { + fetchDashboards(); + + return () => { + fetchDashboards.cancel(); + }; + }, []); + + const dashboardService = useDashboardService(); + + const [isLoading, setIsLoading] = useState(false); + const [selectedSwimlanes, setSelectedSwimlanes] = useState<{ [key in SwimlaneType]: boolean }>({ + [SWIMLANE_TYPE.OVERALL]: true, + [SWIMLANE_TYPE.VIEW_BY]: false, + }); + const [dashboardItems, setDashboardItems] = useState([]); + const [selectedItems, setSelectedItems] = useState([]); + + const fetchDashboards = useCallback( + debounce(async (query?: string) => { + try { + const response = await dashboardService.fetchDashboards(query); + const items: DashboardItem[] = response.savedObjects.map((savedObject) => { + return { + id: savedObject.id, + title: savedObject.attributes.title, + description: savedObject.attributes.description, + attributes: savedObject.attributes, + }; + }); + setDashboardItems(items); + } catch (e) { + toasts.danger({ + body: e, + }); + } + setIsLoading(false); + }, 500), + [] + ); + + const search: EuiTableProps['search'] = useMemo(() => { + return { + onChange: ({ queryText }) => { + setIsLoading(true); + fetchDashboards(queryText); + }, + box: { + incremental: true, + 'data-test-subj': 'mlDashboardsSearchBox', + }, + }; + }, []); + + const addSwimlaneToDashboardCallback = useCallback(async () => { + const swimlanes = Object.entries(selectedSwimlanes) + .filter(([, isSelected]) => isSelected) + .map(([swimlaneType]) => swimlaneType); + + for (const selectedDashboard of selectedItems) { + const panelsData = swimlanes.map((swimlaneType) => { + const config = getDefaultEmbeddablepaPanelConfig(jobIds); + if (swimlaneType === SWIMLANE_TYPE.VIEW_BY) { + return { + ...config, + embeddableConfig: { + jobIds, + swimlaneType, + viewBy, + limit, + }, + }; + } + return { + ...config, + embeddableConfig: { + jobIds, + swimlaneType, + }, + }; + }); + + try { + await dashboardService.attachPanels( + selectedDashboard.id, + selectedDashboard.attributes, + panelsData + ); + toasts.success({ + title: ( + + ), + toastLifeTimeMs: 3000, + }); + } catch (e) { + toasts.danger({ + body: e, + }); + } + } + }, [selectedSwimlanes, selectedItems]); + + const columns: EuiTableProps['columns'] = [ + { + field: 'title', + name: i18n.translate('xpack.ml.explorer.dashboardsTable.titleColumnHeader', { + defaultMessage: 'Title', + }), + sortable: true, + truncateText: true, + }, + { + field: 'description', + name: i18n.translate('xpack.ml.explorer.dashboardsTable.descriptionColumnHeader', { + defaultMessage: 'Description', + }), + truncateText: true, + }, + ]; + + const swimlaneTypeOptions = [ + { + id: SWIMLANE_TYPE.OVERALL, + label: i18n.translate('xpack.ml.explorer.overallLabel', { + defaultMessage: 'Overall', + }), + }, + { + id: SWIMLANE_TYPE.VIEW_BY, + label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', { + defaultMessage: 'View by {viewByField}, up to {limit} rows', + values: { viewByField: viewBy, limit }, + }), + }, + ]; + + const selection: EuiTableProps['selection'] = { + onSelectionChange: setSelectedItems, + }; + + const noSwimlaneSelected = Object.values(selectedSwimlanes).every((isSelected) => !isSelected); + + return ( + + + + + + + + + + } + > + { + const newSelection = { + ...selectedSwimlanes, + [optionId]: !selectedSwimlanes[optionId as SwimlaneType], + }; + setSelectedSwimlanes(newSelection); + }} + data-test-subj="mlAddToDashboardSwimlaneTypeSelector" + /> + + + + + + } + data-test-subj="mlDashboardSelectionContainer" + > + + + + + + + + { + onClose(async () => { + const selectedDashboardId = selectedItems[0].id; + await addSwimlaneToDashboardCallback(); + await navigateToUrl( + await dashboardService.getDashboardEditUrl(selectedDashboardId) + ); + }); + }} + data-test-subj="mlAddAndEditDashboardButton" + > + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx new file mode 100644 index 00000000000000..b4d32e2af64b8e --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -0,0 +1,392 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useCallback, useMemo, useRef, useState } from 'react'; +import { isEqual } from 'lodash'; +import DragSelect from 'dragselect'; +import { + EuiPanel, + EuiPopover, + EuiContextMenuPanel, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiTitle, + EuiSpacer, + EuiContextMenuItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DRAG_SELECT_ACTION, VIEW_BY_JOB_LABEL } from './explorer_constants'; +import { AddToDashboardControl } from './add_to_dashboard_control'; +import { useMlKibana } from '../contexts/kibana'; +import { TimeBuckets } from '../util/time_buckets'; +import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; +import { SelectLimit } from './select_limit'; +import { + ALLOW_CELL_RANGE_SELECTION, + dragSelect$, + explorerService, +} from './explorer_dashboard_service'; +import { ExplorerState } from './reducers/explorer_reducer'; +import { hasMatchingPoints } from './has_matching_points'; +import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found/explorer_no_influencers_found'; +import { LoadingIndicator } from '../components/loading_indicator'; +import { SwimlaneContainer } from './swimlane_container'; +import { OverallSwimlaneData } from './explorer_utils'; + +function mapSwimlaneOptionsToEuiOptions(options: string[]) { + return options.map((option) => ({ + value: option, + text: option, + })); +} + +interface AnomalyTimelineProps { + explorerState: ExplorerState; + setSelectedCells: (cells?: any) => void; +} + +export const AnomalyTimeline: FC = React.memo( + ({ explorerState, setSelectedCells }) => { + const { + services: { + uiSettings, + application: { capabilities }, + }, + } = useMlKibana(); + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false); + + const isSwimlaneSelectActive = useRef(false); + // make sure dragSelect is only available if the mouse pointer is actually over a swimlane + const disableDragSelectOnMouseLeave = useRef(true); + + const canEditDashboards = capabilities.dashboard?.createNew ?? false; + + const timeBuckets = useMemo(() => { + return new TimeBuckets({ + 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + dateFormat: uiSettings.get('dateFormat'), + 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), + }); + }, [uiSettings]); + + const dragSelect = useMemo( + () => + new DragSelect({ + selectorClass: 'ml-swimlane-selector', + selectables: document.querySelectorAll('.sl-cell'), + callback(elements) { + if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { + elements = [elements[0]]; + } + + if (elements.length > 0) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.NEW_SELECTION, + elements, + }); + } + + disableDragSelectOnMouseLeave.current = true; + }, + onDragStart(e) { + let target = e.target as HTMLElement; + while (target && target !== document.body && !target.classList.contains('sl-cell')) { + target = target.parentNode as HTMLElement; + } + if (ALLOW_CELL_RANGE_SELECTION && target !== document.body) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.DRAG_START, + }); + disableDragSelectOnMouseLeave.current = false; + } + }, + onElementSelect() { + if (ALLOW_CELL_RANGE_SELECTION) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.ELEMENT_SELECT, + }); + } + }, + }), + [] + ); + + const { + filterActive, + filteredFields, + maskAll, + overallSwimlaneData, + selectedCells, + viewByLoadedForTimeFormatted, + viewBySwimlaneData, + viewBySwimlaneDataLoading, + viewBySwimlaneFieldName, + viewBySwimlaneOptions, + swimlaneLimit, + selectedJobs, + } = explorerState; + + const setSwimlaneSelectActive = useCallback((active: boolean) => { + if (isSwimlaneSelectActive.current && !active && disableDragSelectOnMouseLeave.current) { + dragSelect.stop(); + isSwimlaneSelectActive.current = active; + return; + } + if (!isSwimlaneSelectActive.current && active) { + dragSelect.start(); + dragSelect.clearSelection(); + dragSelect.setSelectables(document.querySelectorAll('.sl-cell')); + isSwimlaneSelectActive.current = active; + } + }, []); + const onSwimlaneEnterHandler = () => setSwimlaneSelectActive(true); + const onSwimlaneLeaveHandler = () => setSwimlaneSelectActive(false); + + // Listens to render updates of the swimlanes to update dragSelect + const swimlaneRenderDoneListener = useCallback(() => { + dragSelect.clearSelection(); + dragSelect.setSelectables(document.querySelectorAll('.sl-cell')); + }, []); + + // Listener for click events in the swimlane to load corresponding anomaly data. + const swimlaneCellClick = useCallback((selectedCellsUpdate: any) => { + // If selectedCells is an empty object we clear any existing selection, + // otherwise we save the new selection in AppState and update the Explorer. + if (Object.keys(selectedCellsUpdate).length === 0) { + setSelectedCells(); + } else { + setSelectedCells(selectedCellsUpdate); + } + }, []); + + const showOverallSwimlane = + overallSwimlaneData !== null && + overallSwimlaneData.laneLabels && + overallSwimlaneData.laneLabels.length > 0; + + const showViewBySwimlane = + viewBySwimlaneData !== null && + viewBySwimlaneData.laneLabels && + viewBySwimlaneData.laneLabels.length > 0; + + const menuItems = useMemo(() => { + const items = []; + if (canEditDashboards) { + items.push( + + + + ); + } + return items; + }, [canEditDashboards]); + + return ( + <> + + + + +

+ +

+
+
+ {viewBySwimlaneOptions.length > 0 && ( + <> + + + + + } + display={'columnCompressed'} + > + explorerService.setViewBySwimlaneFieldName(e.target.value)} + /> + + + + + + + } + display={'columnCompressed'} + > + + + + +
+ {viewByLoadedForTimeFormatted && ( + + )} + {viewByLoadedForTimeFormatted === undefined && ( + + )} + {filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && ( + + )} +
+
+ + )} + + {menuItems.length > 0 && ( + + + } + isOpen={isMenuOpen} + closePopover={setIsMenuOpen.bind(null, false)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + + )} +
+ + + +
+ {showOverallSwimlane && ( + explorerService.setSwimlaneContainerWidth(width)} + /> + )} +
+ + {viewBySwimlaneOptions.length > 0 && ( + <> + {showViewBySwimlane && ( + <> + +
+ explorerService.setSwimlaneContainerWidth(width)} + /> +
+ + )} + + {viewBySwimlaneDataLoading && } + + {!showViewBySwimlane && + !viewBySwimlaneDataLoading && + typeof viewBySwimlaneFieldName === 'string' && ( + + )} + + )} +
+ {isAddDashboardsActive && selectedJobs && ( + { + setIsAddDashboardActive(false); + if (callback) { + await callback(); + } + }} + jobIds={selectedJobs.map(({ id }) => id)} + viewBy={viewBySwimlaneFieldName!} + limit={swimlaneLimit} + /> + )} + + ); + }, + (prevProps, nextProps) => { + return isEqual(prevProps.explorerState, nextProps.explorerState); + } +); diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx similarity index 78% rename from x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.js rename to x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx index 5f54c383e76ad7..639c0f7b785043 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.js +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx @@ -4,20 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -/* - * React component for rendering EuiEmptyPrompt when no influencers were found. - */ - -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiEmptyPrompt } from '@elastic/eui'; -export const ExplorerNoInfluencersFound = ({ - viewBySwimlaneFieldName, - showFilterMessage = false, -}) => ( +/* + * React component for rendering EuiEmptyPrompt when no influencers were found. + */ +export const ExplorerNoInfluencersFound: FC<{ + viewBySwimlaneFieldName: string; + showFilterMessage?: boolean; +}> = ({ viewBySwimlaneFieldName, showFilterMessage = false }) => ( ); - -ExplorerNoInfluencersFound.propTypes = { - viewBySwimlaneFieldName: PropTypes.string.isRequired, - showFilterMessage: PropTypes.bool, -}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index 1a5a9a9d828623..71c96840d1b579 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -9,10 +9,9 @@ */ import PropTypes from 'prop-types'; -import React, { createRef } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import DragSelect from 'dragselect/dist/ds.min.js'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -26,34 +25,23 @@ import { EuiPageBody, EuiPageHeader, EuiPageHeaderSection, - EuiSelect, EuiSpacer, EuiTitle, } from '@elastic/eui'; import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; import { AnnotationsTable } from '../components/annotations/annotations_table'; -import { - ExplorerNoInfluencersFound, - ExplorerNoJobsFound, - ExplorerNoResultsFound, -} from './components'; -import { ExplorerSwimlane } from './explorer_swimlane'; -import { getTimeBucketsFromCache } from '../util/time_buckets'; +import { ExplorerNoJobsFound, ExplorerNoResultsFound } from './components'; import { DatePickerWrapper } from '../components/navigation_menu/date_picker_wrapper'; import { InfluencersList } from '../components/influencers_list'; -import { - ALLOW_CELL_RANGE_SELECTION, - dragSelect$, - explorerService, -} from './explorer_dashboard_service'; +import { explorerService } from './explorer_dashboard_service'; import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector'; import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; import { NavigationMenu } from '../components/navigation_menu'; import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts'; import { JobSelector } from '../components/job_selector'; import { SelectInterval } from '../components/controls/select_interval/select_interval'; -import { SelectLimit, limit$ } from './select_limit/select_limit'; +import { limit$ } from './select_limit/select_limit'; import { SelectSeverity } from '../components/controls/select_severity/select_severity'; import { ExplorerQueryBar, @@ -67,14 +55,9 @@ import { escapeParens, escapeDoubleQuotes, } from './explorer_utils'; -import { getSwimlaneContainerWidth } from './legacy_utils'; +import { AnomalyTimeline } from './anomaly_timeline'; -import { - DRAG_SELECT_ACTION, - FILTER_ACTION, - SWIMLANE_TYPE, - VIEW_BY_JOB_LABEL, -} from './explorer_constants'; +import { FILTER_ACTION } from './explorer_constants'; // Explorer Charts import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container'; @@ -82,17 +65,7 @@ import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_conta // Anomalies Table import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; -import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public'; import { getTimefilter, getToastNotifications } from '../util/dependency_cache'; -import { MlTooltipComponent } from '../components/chart_tooltip'; -import { hasMatchingPoints } from './has_matching_points'; - -function mapSwimlaneOptionsToEuiOptions(options) { - return options.map((option) => ({ - value: option, - text: option, - })); -} const ExplorerPage = ({ children, @@ -105,9 +78,8 @@ const ExplorerPage = ({ queryString, filterIconTriggeredQuery, updateLanguage, - resizeRef, }) => ( -
+
@@ -171,108 +143,18 @@ export class Explorer extends React.Component { state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG }; _unsubscribeAll = new Subject(); - // make sure dragSelect is only available if the mouse pointer is actually over a swimlane - disableDragSelectOnMouseLeave = true; - - dragSelect = new DragSelect({ - selectorClass: 'ml-swimlane-selector', - selectables: document.getElementsByClassName('sl-cell'), - callback(elements) { - if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { - elements = [elements[0]]; - } - - if (elements.length > 0) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.NEW_SELECTION, - elements, - }); - } - - this.disableDragSelectOnMouseLeave = true; - }, - onDragStart(e) { - let target = e.target; - while (target && target !== document.body && !target.classList.contains('sl-cell')) { - target = target.parentNode; - } - if (ALLOW_CELL_RANGE_SELECTION && target !== document.body) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.DRAG_START, - }); - this.disableDragSelectOnMouseLeave = false; - } - }, - onElementSelect() { - if (ALLOW_CELL_RANGE_SELECTION) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.ELEMENT_SELECT, - }); - } - }, - }); - - // Listens to render updates of the swimlanes to update dragSelect - swimlaneRenderDoneListener = () => { - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); - }; - - resizeRef = createRef(); - resizeChecker = undefined; - resizeHandler = () => { - explorerService.setSwimlaneContainerWidth(getSwimlaneContainerWidth()); - }; componentDidMount() { limit$.pipe(takeUntil(this._unsubscribeAll)).subscribe(explorerService.setSwimlaneLimit); - - // Required to redraw the time series chart when the container is resized. - this.resizeChecker = new ResizeChecker(this.resizeRef.current); - this.resizeChecker.on('resize', this.resizeHandler); - - this.timeBuckets = getTimeBucketsFromCache(); } componentWillUnmount() { this._unsubscribeAll.next(); this._unsubscribeAll.complete(); - this.resizeChecker.destroy(); - } - - resetCache() { - this.anomaliesTablePreviousArgs = null; } viewByChangeHandler = (e) => explorerService.setViewBySwimlaneFieldName(e.target.value); - isSwimlaneSelectActive = false; - onSwimlaneEnterHandler = () => this.setSwimlaneSelectActive(true); - onSwimlaneLeaveHandler = () => this.setSwimlaneSelectActive(false); - setSwimlaneSelectActive = (active) => { - if (this.isSwimlaneSelectActive && !active && this.disableDragSelectOnMouseLeave) { - this.dragSelect.stop(); - this.isSwimlaneSelectActive = active; - return; - } - if (!this.isSwimlaneSelectActive && active) { - this.dragSelect.start(); - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); - this.isSwimlaneSelectActive = active; - } - }; - - // Listener for click events in the swimlane to load corresponding anomaly data. - swimlaneCellClick = (selectedCells) => { - // If selectedCells is an empty object we clear any existing selection, - // otherwise we save the new selection in AppState and update the Explorer. - if (Object.keys(selectedCells).length === 0) { - this.props.setSelectedCells(); - } else { - this.props.setSelectedCells(selectedCells); - } - }; // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes // and will cause a syntax error when called with getKqlQueryValues applyFilter = (fieldName, fieldValue, action) => { @@ -339,24 +221,16 @@ export class Explorer extends React.Component { annotationsData, chartsData, filterActive, - filteredFields, filterPlaceHolder, indexPattern, influencers, loading, - maskAll, noInfluencersConfigured, overallSwimlaneData, queryString, selectedCells, selectedJobs, - swimlaneContainerWidth, tableData, - viewByLoadedForTimeFormatted, - viewBySwimlaneData, - viewBySwimlaneDataLoading, - viewBySwimlaneFieldName, - viewBySwimlaneOptions, } = this.props.explorerState; const jobSelectorProps = { @@ -378,7 +252,6 @@ export class Explorer extends React.Component { indexPattern={indexPattern} queryString={queryString} updateLanguage={this.updateLanguage} - resizeRef={this.resizeRef} > + ); @@ -399,7 +272,7 @@ export class Explorer extends React.Component { if (noJobsFound && hasResults === false) { return ( - + ); @@ -408,15 +281,6 @@ export class Explorer extends React.Component { const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; const mainColumnClasses = `column ${mainColumnWidthClassName}`; - const showOverallSwimlane = - overallSwimlaneData !== null && - overallSwimlaneData.laneLabels && - overallSwimlaneData.laneLabels.length > 0; - const showViewBySwimlane = - viewBySwimlaneData !== null && - viewBySwimlaneData.laneLabels && - viewBySwimlaneData.laneLabels.length > 0; - const timefilter = getTimefilter(); const bounds = timefilter.getActiveBounds(); @@ -431,7 +295,6 @@ export class Explorer extends React.Component { indexPattern={indexPattern} queryString={queryString} updateLanguage={this.updateLanguage} - resizeRef={this.resizeRef} >
{noInfluencersConfigured && ( @@ -462,142 +325,12 @@ export class Explorer extends React.Component { )}
- -

- -

-
- -
- {showOverallSwimlane && ( - - {(tooltipService) => ( - - )} - - )} -
- - {viewBySwimlaneOptions.length > 0 && ( - <> - - - - - - - - - - - - - -
- {viewByLoadedForTimeFormatted && ( - - )} - {viewByLoadedForTimeFormatted === undefined && ( - - )} - {filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && ( - - )} -
-
-
-
- - {showViewBySwimlane && ( - <> - -
- - {(tooltipService) => ( - - )} - -
- - )} - - {viewBySwimlaneDataLoading && } - - {!showViewBySwimlane && - !viewBySwimlaneDataLoading && - viewBySwimlaneFieldName !== null && ( - - )} - - )} + + + {annotationsData.length > 0 && ( <> diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index 1cfd29e2f60d22..d1adf8c7ad744c 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -37,10 +37,12 @@ export const FILTER_ACTION = { REMOVE: '-', }; -export enum SWIMLANE_TYPE { - OVERALL = 'overall', - VIEW_BY = 'viewBy', -} +export const SWIMLANE_TYPE = { + OVERALL: 'overall', + VIEW_BY: 'viewBy', +} as const; + +export type SwimlaneType = typeof SWIMLANE_TYPE[keyof typeof SWIMLANE_TYPE]; export const CHART_TYPE = { EVENT_DISTRIBUTION: 'event_distribution', diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx index 18b5de1d51f9c5..4e6dcdcc5129ca 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -22,7 +22,7 @@ import { numTicksForDateFormat } from '../util/chart_utils'; import { getSeverityColor } from '../../../common/util/anomaly_utils'; import { mlEscape } from '../util/string_utils'; import { ALLOW_CELL_RANGE_SELECTION, dragSelect$ } from './explorer_dashboard_service'; -import { DRAG_SELECT_ACTION } from './explorer_constants'; +import { DRAG_SELECT_ACTION, SwimlaneType } from './explorer_constants'; import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets'; import { @@ -58,7 +58,7 @@ export interface ExplorerSwimlaneProps { timeBuckets: InstanceType; swimlaneCellClick?: Function; swimlaneData: OverallSwimlaneData; - swimlaneType: string; + swimlaneType: SwimlaneType; selection?: { lanes: any[]; type: string; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index 0a2dbf5bcff35f..4e1a2af9b13a60 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -16,8 +16,9 @@ import { AnomaliesTableData, ExplorerJob, AppStateSelectedCells, - SwimlaneData, TimeRangeBounds, + OverallSwimlaneData, + SwimlaneData, } from '../../explorer_utils'; export interface ExplorerState { @@ -35,7 +36,7 @@ export interface ExplorerState { loading: boolean; maskAll: boolean; noInfluencersConfigured: boolean; - overallSwimlaneData: SwimlaneData; + overallSwimlaneData: SwimlaneData | OverallSwimlaneData; queryString: string; selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[] | null; @@ -45,7 +46,7 @@ export interface ExplorerState { tableData: AnomaliesTableData; tableQueryString: string; viewByLoadedForTimeFormatted: string | null; - viewBySwimlaneData: SwimlaneData; + viewBySwimlaneData: SwimlaneData | OverallSwimlaneData; viewBySwimlaneDataLoading: boolean; viewBySwimlaneFieldName?: string; viewBySwimlaneOptions: string[]; diff --git a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx index 7f7a8fc5a70bd0..7a2df1a0f05350 100644 --- a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx +++ b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx @@ -36,5 +36,5 @@ export const SelectLimit = () => { setLimit(parseInt(e.target.value, 10)); } - return ; + return ; }; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx new file mode 100644 index 00000000000000..57d1fd81000b7e --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useCallback, useState } from 'react'; +import { EuiResizeObserver, EuiText } from '@elastic/eui'; + +import { throttle } from 'lodash'; +import { + ExplorerSwimlane, + ExplorerSwimlaneProps, +} from '../../application/explorer/explorer_swimlane'; + +import { MlTooltipComponent } from '../../application/components/chart_tooltip'; + +const RESIZE_THROTTLE_TIME_MS = 500; + +export const SwimlaneContainer: FC< + Omit & { + onResize: (width: number) => void; + } +> = ({ children, onResize, ...props }) => { + const [chartWidth, setChartWidth] = useState(0); + + const resizeHandler = useCallback( + throttle((e: { width: number; height: number }) => { + const labelWidth = 200; + setChartWidth(e.width - labelWidth); + onResize(e.width); + }, RESIZE_THROTTLE_TIME_MS), + [] + ); + + return ( + + {(resizeRef) => ( +
{ + resizeRef(el); + }} + > +
+ + + {(tooltipService) => ( + + )} + + +
+
+ )} +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts new file mode 100644 index 00000000000000..6cab23eb187c7c --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { dashboardServiceProvider } from './dashboard_service'; +import { savedObjectsServiceMock } from '../../../../../../src/core/public/mocks'; +import { SavedObjectDashboard } from '../../../../../../src/plugins/dashboard/public/saved_dashboards'; +import { + DashboardUrlGenerator, + SavedDashboardPanel, +} from '../../../../../../src/plugins/dashboard/public'; + +jest.mock('@elastic/eui', () => { + return { + htmlIdGenerator: jest.fn(() => { + return jest.fn(() => 'test-panel-id'); + }), + }; +}); + +describe('DashboardService', () => { + const mockSavedObjectClient = savedObjectsServiceMock.createStartContract().client; + const dashboardUrlGenerator = ({ + createUrl: jest.fn(), + } as unknown) as DashboardUrlGenerator; + const dashboardService = dashboardServiceProvider( + mockSavedObjectClient, + '8.0.0', + dashboardUrlGenerator + ); + + test('should fetch dashboard', () => { + // act + dashboardService.fetchDashboards('test'); + // assert + expect(mockSavedObjectClient.find).toHaveBeenCalledWith({ + type: 'dashboard', + perPage: 10, + search: `test*`, + searchFields: ['title^3', 'description'], + }); + }); + + test('should attach panel to the dashboard', () => { + // act + dashboardService.attachPanels( + 'test-dashboard', + ({ + title: 'ML Test', + hits: 0, + description: '', + panelsJSON: JSON.stringify([ + { + version: '8.0.0', + type: 'ml_anomaly_swimlane', + gridData: { x: 0, y: 0, w: 24, h: 15, i: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f' }, + panelIndex: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f', + embeddableConfig: { + title: 'Panel test!', + jobIds: ['cw_multi_1'], + swimlaneType: 'overall', + }, + title: 'Panel test!', + }, + { + version: '8.0.0', + type: 'ml_anomaly_swimlane', + gridData: { x: 24, y: 0, w: 24, h: 15, i: '0aa334bd-8308-4ded-9462-80dbd37680ee' }, + panelIndex: '0aa334bd-8308-4ded-9462-80dbd37680ee', + embeddableConfig: { + title: 'ML anomaly swimlane for fb_population_1', + jobIds: ['fb_population_1'], + limit: 5, + swimlaneType: 'overall', + }, + title: 'ML anomaly swimlane for fb_population_1', + }, + { + version: '8.0.0', + gridData: { x: 0, y: 15, w: 24, h: 15, i: 'abd36eb7-4774-4216-891e-12100752b46d' }, + panelIndex: 'abd36eb7-4774-4216-891e-12100752b46d', + embeddableConfig: {}, + panelRefName: 'panel_2', + }, + ]), + optionsJSON: '{"hidePanelTitles":false,"useMargins":true}', + version: 1, + timeRestore: false, + kibanaSavedObjectMeta: { + searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}', + }, + } as unknown) as SavedObjectDashboard, + [{ title: 'Test title', type: 'test-panel', embeddableConfig: { testConfig: '' } }] + ); + // assert + expect(mockSavedObjectClient.update).toHaveBeenCalledWith('dashboard', 'test-dashboard', { + title: 'ML Test', + hits: 0, + description: '', + panelsJSON: JSON.stringify([ + { + version: '8.0.0', + type: 'ml_anomaly_swimlane', + gridData: { x: 0, y: 0, w: 24, h: 15, i: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f' }, + panelIndex: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f', + embeddableConfig: { + title: 'Panel test!', + jobIds: ['cw_multi_1'], + swimlaneType: 'overall', + }, + title: 'Panel test!', + }, + { + version: '8.0.0', + type: 'ml_anomaly_swimlane', + gridData: { x: 24, y: 0, w: 24, h: 15, i: '0aa334bd-8308-4ded-9462-80dbd37680ee' }, + panelIndex: '0aa334bd-8308-4ded-9462-80dbd37680ee', + embeddableConfig: { + title: 'ML anomaly swimlane for fb_population_1', + jobIds: ['fb_population_1'], + limit: 5, + swimlaneType: 'overall', + }, + title: 'ML anomaly swimlane for fb_population_1', + }, + { + version: '8.0.0', + gridData: { x: 0, y: 15, w: 24, h: 15, i: 'abd36eb7-4774-4216-891e-12100752b46d' }, + panelIndex: 'abd36eb7-4774-4216-891e-12100752b46d', + embeddableConfig: {}, + panelRefName: 'panel_2', + }, + { + panelIndex: 'test-panel-id', + embeddableConfig: { testConfig: '' }, + title: 'Test title', + type: 'test-panel', + version: '8.0.0', + gridData: { h: 15, i: 'test-panel-id', w: 24, x: 24, y: 15 }, + }, + ]), + optionsJSON: '{"hidePanelTitles":false,"useMargins":true}', + version: 1, + timeRestore: false, + kibanaSavedObjectMeta: { + searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}', + }, + }); + }); + + test('should generate edit url to the dashboard', () => { + dashboardService.getDashboardEditUrl('test-id'); + expect(dashboardUrlGenerator.createUrl).toHaveBeenCalledWith({ + dashboardId: 'test-id', + useHash: false, + viewMode: 'edit', + }); + }); + + test('should find the panel positioned at the end', () => { + expect( + dashboardService.getLastPanel([ + { gridData: { y: 15, x: 7 } }, + { gridData: { y: 17, x: 9 } }, + { gridData: { y: 15, x: 1 } }, + { gridData: { y: 17, x: 10 } }, + { gridData: { y: 15, x: 22 } }, + { gridData: { y: 17, x: 9 } }, + ] as SavedDashboardPanel[]) + ).toEqual({ gridData: { y: 17, x: 10 } }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.ts new file mode 100644 index 00000000000000..7f2bb71d18eb98 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/public'; +import { htmlIdGenerator } from '@elastic/eui'; +import { useMemo } from 'react'; +import { + DASHBOARD_APP_URL_GENERATOR, + DashboardUrlGenerator, + SavedDashboardPanel, + SavedObjectDashboard, +} from '../../../../../../src/plugins/dashboard/public'; +import { useMlKibana } from '../contexts/kibana'; +import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; + +export type DashboardService = ReturnType; + +export function dashboardServiceProvider( + savedObjectClient: SavedObjectsClientContract, + kibanaVersion: string, + dashboardUrlGenerator: DashboardUrlGenerator +) { + const generateId = htmlIdGenerator(); + const DEFAULT_PANEL_WIDTH = 24; + const DEFAULT_PANEL_HEIGHT = 15; + + return { + /** + * Fetches dashboards + */ + async fetchDashboards(query?: string) { + return await savedObjectClient.find({ + type: 'dashboard', + perPage: 10, + search: query ? `${query}*` : '', + searchFields: ['title^3', 'description'], + }); + }, + /** + * Resolves the last positioned panel from the collection. + */ + getLastPanel(panels: SavedDashboardPanel[]): SavedDashboardPanel | null { + return panels.length > 0 + ? panels.reduce((prev, current) => + prev.gridData.y >= current.gridData.y + ? prev.gridData.y === current.gridData.y + ? prev.gridData.x > current.gridData.x + ? prev + : current + : prev + : current + ) + : null; + }, + /** + * Attaches embeddable panels to the dashboard + */ + async attachPanels( + dashboardId: string, + dashboardAttributes: SavedObjectDashboard, + panelsData: Array> + ) { + const panels = JSON.parse(dashboardAttributes.panelsJSON) as SavedDashboardPanel[]; + const version = kibanaVersion; + const rowWidth = DEFAULT_PANEL_WIDTH * 2; + + for (const panelData of panelsData) { + const panelIndex = generateId(); + const lastPanel = this.getLastPanel(panels); + + const xOffset = lastPanel ? lastPanel.gridData.w + lastPanel.gridData.x : 0; + const availableRowSpace = rowWidth - xOffset; + const xPosition = availableRowSpace - DEFAULT_PANEL_WIDTH >= 0 ? xOffset : 0; + + panels.push({ + panelIndex, + embeddableConfig: panelData.embeddableConfig as { [key: string]: any }, + title: panelData.title, + type: panelData.type, + version, + gridData: { + h: DEFAULT_PANEL_HEIGHT, + i: panelIndex, + w: DEFAULT_PANEL_WIDTH, + x: xPosition, + y: lastPanel + ? xPosition > 0 + ? lastPanel.gridData.y + : lastPanel.gridData.y + lastPanel.gridData.h + : 0, + }, + }); + } + + await savedObjectClient.update('dashboard', dashboardId, { + ...dashboardAttributes, + panelsJSON: JSON.stringify(panels), + }); + }, + /** + * Generates dashboard url with edit mode + */ + async getDashboardEditUrl(dashboardId: string) { + return await dashboardUrlGenerator.createUrl({ + dashboardId, + useHash: false, + viewMode: ViewMode.EDIT, + }); + }, + }; +} + +/** + * Hook to use {@link DashboardService} in react components + */ +export function useDashboardService(): DashboardService { + const { + services: { + savedObjects: { client: savedObjectClient }, + kibanaVersion, + share: { urlGenerators }, + }, + } = useMlKibana(); + return useMemo( + () => + dashboardServiceProvider( + savedObjectClient, + kibanaVersion, + urlGenerators.getUrlGenerator(DASHBOARD_APP_URL_GENERATOR) + ), + [savedObjectClient, kibanaVersion] + ); +} diff --git a/x-pack/plugins/ml/public/application/services/http_service.ts b/x-pack/plugins/ml/public/application/services/http_service.ts index 7144411c2885d2..bd927dc0e30111 100644 --- a/x-pack/plugins/ml/public/application/services/http_service.ts +++ b/x-pack/plugins/ml/public/application/services/http_service.ts @@ -37,6 +37,8 @@ function getFetchOptions( /** * Function for making HTTP requests to Kibana's backend. * Wrapper for Kibana's HttpHandler. + * + * @deprecated use {@link HttpService} instead */ export async function http(options: HttpFetchOptionsWithPath): Promise { const { path, fetchOptions } = getFetchOptions(options); @@ -46,6 +48,8 @@ export async function http(options: HttpFetchOptionsWithPath): Promise { /** * Function for making HTTP requests to Kibana's backend which returns an Observable * with request cancellation support. + * + * @deprecated use {@link HttpService} instead */ export function http$(options: HttpFetchOptionsWithPath): Observable { const { path, fetchOptions } = getFetchOptions(options); @@ -55,7 +59,7 @@ export function http$(options: HttpFetchOptionsWithPath): Observable { /** * Creates an Observable from Kibana's HttpHandler. */ -export function fromHttpHandler(input: string, init?: RequestInit): Observable { +function fromHttpHandler(input: string, init?: RequestInit): Observable { return new Observable((subscriber) => { const controller = new AbortController(); const signal = controller.signal; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index fdaa3c2ffe79ec..6d32fca6a645c1 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -5,12 +5,13 @@ */ import { Observable } from 'rxjs'; -import { http, http$ } from '../http_service'; +import { HttpStart } from 'kibana/public'; +import { HttpService } from '../http_service'; import { annotations } from './annotations'; import { dataFrameAnalytics } from './data_frame_analytics'; import { filters } from './filters'; -import { results } from './results'; +import { resultsApiProvider } from './results'; import { jobs } from './jobs'; import { fileDatavisualizer } from './datavisualizer'; import { MlServerDefaults, MlServerLimits } from '../../../../common/types/ml_server_info'; @@ -28,6 +29,7 @@ import { import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { FieldRequestConfig } from '../../datavisualizer/index_based/common'; import { DataRecognizerConfigResponse, Module } from '../../../../common/types/modules'; +import { getHttp } from '../../util/dependency_cache'; export interface MlInfoResponse { defaults: MlServerDefaults; @@ -87,327 +89,330 @@ export function basePath() { return '/api/ml'; } -export const ml = { - getJobs(obj?: { jobId?: string }) { - const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; - return http({ - path: `${basePath()}/anomaly_detectors${jobId}`, - }); - }, - - getJobStats(obj: { jobId?: string }) { - const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; - return http({ - path: `${basePath()}/anomaly_detectors${jobId}/_stats`, - }); - }, - - addJob({ jobId, job }: { jobId: string; job: Job }) { - const body = JSON.stringify(job); - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}`, - method: 'PUT', - body, - }); - }, - - openJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_open`, - method: 'POST', - }); - }, - - closeJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_close`, - method: 'POST', - }); - }, - - forceCloseJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_close?force=true`, - method: 'POST', - }); - }, - - deleteJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}`, - method: 'DELETE', - }); - }, - - forceDeleteJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}?force=true`, - method: 'DELETE', - }); - }, - - updateJob({ jobId, job }: { jobId: string; job: Job }) { - const body = JSON.stringify(job); - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_update`, - method: 'POST', - body, - }); - }, - - estimateBucketSpan(obj: BucketSpanEstimatorData) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/validate/estimate_bucket_span`, - method: 'POST', - body, - }); - }, - - validateJob(payload: { - job: Job; - duration: { - start?: number; - end?: number; - }; - fields?: any[]; - }) { - const body = JSON.stringify(payload); - return http({ - path: `${basePath()}/validate/job`, - method: 'POST', - body, - }); - }, - - validateCardinality$(job: CombinedJob): Observable { - const body = JSON.stringify(job); - return http$({ - path: `${basePath()}/validate/cardinality`, - method: 'POST', - body, - }); - }, - - getDatafeeds(obj: { datafeedId: string }) { - const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; - return http({ - path: `${basePath()}/datafeeds${datafeedId}`, - }); - }, - - getDatafeedStats(obj: { datafeedId: string }) { - const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; - return http({ - path: `${basePath()}/datafeeds${datafeedId}/_stats`, - }); - }, - - addDatafeed({ datafeedId, datafeedConfig }: { datafeedId: string; datafeedConfig: Datafeed }) { - const body = JSON.stringify(datafeedConfig); - return http({ - path: `${basePath()}/datafeeds/${datafeedId}`, - method: 'PUT', - body, - }); - }, - - updateDatafeed({ datafeedId, datafeedConfig }: { datafeedId: string; datafeedConfig: Datafeed }) { - const body = JSON.stringify(datafeedConfig); - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_update`, - method: 'POST', - body, - }); - }, - - deleteDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}`, - method: 'DELETE', - }); - }, - - forceDeleteDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}?force=true`, - method: 'DELETE', - }); - }, - - startDatafeed({ datafeedId, start, end }: { datafeedId: string; start: number; end: number }) { - const body = JSON.stringify({ - ...(start !== undefined ? { start } : {}), - ...(end !== undefined ? { end } : {}), - }); - - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_start`, - method: 'POST', - body, - }); - }, - - stopDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_stop`, - method: 'POST', - }); - }, - - forceStopDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_stop?force=true`, - method: 'POST', - }); - }, - - datafeedPreview({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_preview`, - method: 'GET', - }); - }, - - validateDetector({ detector }: { detector: Detector }) { - const body = JSON.stringify(detector); - return http({ - path: `${basePath()}/anomaly_detectors/_validate/detector`, - method: 'POST', - body, - }); - }, - - forecast({ jobId, duration }: { jobId: string; duration?: string }) { - const body = JSON.stringify({ - ...(duration !== undefined ? { duration } : {}), - }); - - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_forecast`, - method: 'POST', - body, - }); - }, - - overallBuckets({ - jobId, - topN, - bucketSpan, - start, - end, - }: { - jobId: string; - topN: string; - bucketSpan: string; - start: number; - end: number; - }) { - const body = JSON.stringify({ topN, bucketSpan, start, end }); - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/results/overall_buckets`, - method: 'POST', - body, - }); - }, - - hasPrivileges(obj: any) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/_has_privileges`, - method: 'POST', - body, - }); - }, - - checkMlCapabilities() { - return http({ - path: `${basePath()}/ml_capabilities`, - method: 'GET', - }); - }, - - checkManageMLCapabilities() { - return http({ - path: `${basePath()}/ml_capabilities`, - method: 'GET', - }); - }, - - getNotificationSettings() { - return http({ - path: `${basePath()}/notification_settings`, - method: 'GET', - }); - }, - - getFieldCaps({ index, fields }: { index: string; fields: string[] }) { - const body = JSON.stringify({ - ...(index !== undefined ? { index } : {}), - ...(fields !== undefined ? { fields } : {}), - }); - - return http({ - path: `${basePath()}/indices/field_caps`, - method: 'POST', - body, - }); - }, - - recognizeIndex({ indexPatternTitle }: { indexPatternTitle: string }) { - return http({ - path: `${basePath()}/modules/recognize/${indexPatternTitle}`, - method: 'GET', - }); - }, - - listDataRecognizerModules() { - return http({ - path: `${basePath()}/modules/get_module`, - method: 'GET', - }); - }, - - getDataRecognizerModule({ moduleId }: { moduleId: string }) { - return http({ - path: `${basePath()}/modules/get_module/${moduleId}`, - method: 'GET', - }); - }, - - dataRecognizerModuleJobsExist({ moduleId }: { moduleId: string }) { - return http({ - path: `${basePath()}/modules/jobs_exist/${moduleId}`, - method: 'GET', - }); +/** + * Temp solution to allow {@link ml} service to use http from + * the dependency_cache. + */ +const proxyHttpStart = new Proxy(({} as unknown) as HttpStart, { + get(obj, prop: keyof HttpStart) { + try { + return getHttp()[prop]; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } }, - - setupDataRecognizerConfig({ - moduleId, - prefix, - groups, - indexPatternName, - query, - useDedicatedIndex, - startDatafeed, - start, - end, - jobOverrides, - estimateModelMemory, - }: { - moduleId: string; - prefix?: string; - groups?: string[]; - indexPatternName?: string; - query?: any; - useDedicatedIndex?: boolean; - startDatafeed?: boolean; - start?: number; - end?: number; - jobOverrides?: Array>; - estimateModelMemory?: boolean; - }) { - const body = JSON.stringify({ +}); + +export type MlApiServices = ReturnType; + +export const ml = mlApiServicesProvider(new HttpService(proxyHttpStart)); + +export function mlApiServicesProvider(httpService: HttpService) { + const { http } = httpService; + return { + getJobs(obj?: { jobId?: string }) { + const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; + return httpService.http({ + path: `${basePath()}/anomaly_detectors${jobId}`, + }); + }, + + getJobStats(obj: { jobId?: string }) { + const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; + return httpService.http({ + path: `${basePath()}/anomaly_detectors${jobId}/_stats`, + }); + }, + + addJob({ jobId, job }: { jobId: string; job: Job }) { + const body = JSON.stringify(job); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}`, + method: 'PUT', + body, + }); + }, + + openJob({ jobId }: { jobId: string }) { + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_open`, + method: 'POST', + }); + }, + + closeJob({ jobId }: { jobId: string }) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_close`, + method: 'POST', + }); + }, + + forceCloseJob({ jobId }: { jobId: string }) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_close?force=true`, + method: 'POST', + }); + }, + + deleteJob({ jobId }: { jobId: string }) { + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}`, + method: 'DELETE', + }); + }, + + forceDeleteJob({ jobId }: { jobId: string }) { + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}?force=true`, + method: 'DELETE', + }); + }, + + updateJob({ jobId, job }: { jobId: string; job: Job }) { + const body = JSON.stringify(job); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_update`, + method: 'POST', + body, + }); + }, + + estimateBucketSpan(obj: BucketSpanEstimatorData) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/validate/estimate_bucket_span`, + method: 'POST', + body, + }); + }, + + validateJob(payload: { + job: Job; + duration: { + start?: number; + end?: number; + }; + fields?: any[]; + }) { + const body = JSON.stringify(payload); + return httpService.http({ + path: `${basePath()}/validate/job`, + method: 'POST', + body, + }); + }, + + validateCardinality$(job: CombinedJob): Observable { + const body = JSON.stringify(job); + return httpService.http$({ + path: `${basePath()}/validate/cardinality`, + method: 'POST', + body, + }); + }, + + getDatafeeds(obj: { datafeedId: string }) { + const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; + return httpService.http({ + path: `${basePath()}/datafeeds${datafeedId}`, + }); + }, + + getDatafeedStats(obj: { datafeedId: string }) { + const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; + return httpService.http({ + path: `${basePath()}/datafeeds${datafeedId}/_stats`, + }); + }, + + addDatafeed({ datafeedId, datafeedConfig }: { datafeedId: string; datafeedConfig: Datafeed }) { + const body = JSON.stringify(datafeedConfig); + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}`, + method: 'PUT', + body, + }); + }, + + updateDatafeed({ + datafeedId, + datafeedConfig, + }: { + datafeedId: string; + datafeedConfig: Datafeed; + }) { + const body = JSON.stringify(datafeedConfig); + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}/_update`, + method: 'POST', + body, + }); + }, + + deleteDatafeed({ datafeedId }: { datafeedId: string }) { + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}`, + method: 'DELETE', + }); + }, + + forceDeleteDatafeed({ datafeedId }: { datafeedId: string }) { + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}?force=true`, + method: 'DELETE', + }); + }, + + startDatafeed({ datafeedId, start, end }: { datafeedId: string; start: number; end: number }) { + const body = JSON.stringify({ + ...(start !== undefined ? { start } : {}), + ...(end !== undefined ? { end } : {}), + }); + + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}/_start`, + method: 'POST', + body, + }); + }, + + stopDatafeed({ datafeedId }: { datafeedId: string }) { + return http({ + path: `${basePath()}/datafeeds/${datafeedId}/_stop`, + method: 'POST', + }); + }, + + forceStopDatafeed({ datafeedId }: { datafeedId: string }) { + return http({ + path: `${basePath()}/datafeeds/${datafeedId}/_stop?force=true`, + method: 'POST', + }); + }, + + datafeedPreview({ datafeedId }: { datafeedId: string }) { + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}/_preview`, + method: 'GET', + }); + }, + + validateDetector({ detector }: { detector: Detector }) { + const body = JSON.stringify(detector); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/_validate/detector`, + method: 'POST', + body, + }); + }, + + forecast({ jobId, duration }: { jobId: string; duration?: string }) { + const body = JSON.stringify({ + ...(duration !== undefined ? { duration } : {}), + }); + + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_forecast`, + method: 'POST', + body, + }); + }, + + overallBuckets({ + jobId, + topN, + bucketSpan, + start, + end, + }: { + jobId: string; + topN: string; + bucketSpan: string; + start: number; + end: number; + }) { + const body = JSON.stringify({ topN, bucketSpan, start, end }); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/results/overall_buckets`, + method: 'POST', + body, + }); + }, + + hasPrivileges(obj: any) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/_has_privileges`, + method: 'POST', + body, + }); + }, + + checkMlCapabilities() { + return httpService.http({ + path: `${basePath()}/ml_capabilities`, + method: 'GET', + }); + }, + + checkManageMLCapabilities() { + return httpService.http({ + path: `${basePath()}/ml_capabilities`, + method: 'GET', + }); + }, + + getNotificationSettings() { + return httpService.http({ + path: `${basePath()}/notification_settings`, + method: 'GET', + }); + }, + + getFieldCaps({ index, fields }: { index: string; fields: string[] }) { + const body = JSON.stringify({ + ...(index !== undefined ? { index } : {}), + ...(fields !== undefined ? { fields } : {}), + }); + + return httpService.http({ + path: `${basePath()}/indices/field_caps`, + method: 'POST', + body, + }); + }, + + recognizeIndex({ indexPatternTitle }: { indexPatternTitle: string }) { + return httpService.http({ + path: `${basePath()}/modules/recognize/${indexPatternTitle}`, + method: 'GET', + }); + }, + + listDataRecognizerModules() { + return httpService.http({ + path: `${basePath()}/modules/get_module`, + method: 'GET', + }); + }, + + getDataRecognizerModule({ moduleId }: { moduleId: string }) { + return httpService.http({ + path: `${basePath()}/modules/get_module/${moduleId}`, + method: 'GET', + }); + }, + + dataRecognizerModuleJobsExist({ moduleId }: { moduleId: string }) { + return httpService.http({ + path: `${basePath()}/modules/jobs_exist/${moduleId}`, + method: 'GET', + }); + }, + + setupDataRecognizerConfig({ + moduleId, prefix, groups, indexPatternName, @@ -418,37 +423,41 @@ export const ml = { end, jobOverrides, estimateModelMemory, - }); - - return http({ - path: `${basePath()}/modules/setup/${moduleId}`, - method: 'POST', - body, - }); - }, - - getVisualizerFieldStats({ - indexPatternTitle, - query, - timeFieldName, - earliest, - latest, - samplerShardSize, - interval, - fields, - maxExamples, - }: { - indexPatternTitle: string; - query: any; - timeFieldName?: string; - earliest?: number; - latest?: number; - samplerShardSize?: number; - interval?: string; - fields?: FieldRequestConfig[]; - maxExamples?: number; - }) { - const body = JSON.stringify({ + }: { + moduleId: string; + prefix?: string; + groups?: string[]; + indexPatternName?: string; + query?: any; + useDedicatedIndex?: boolean; + startDatafeed?: boolean; + start?: number; + end?: number; + jobOverrides?: Array>; + estimateModelMemory?: boolean; + }) { + const body = JSON.stringify({ + prefix, + groups, + indexPatternName, + query, + useDedicatedIndex, + startDatafeed, + start, + end, + jobOverrides, + estimateModelMemory, + }); + + return httpService.http({ + path: `${basePath()}/modules/setup/${moduleId}`, + method: 'POST', + body, + }); + }, + + getVisualizerFieldStats({ + indexPatternTitle, query, timeFieldName, earliest, @@ -457,35 +466,37 @@ export const ml = { interval, fields, maxExamples, - }); - - return http({ - path: `${basePath()}/data_visualizer/get_field_stats/${indexPatternTitle}`, - method: 'POST', - body, - }); - }, - - getVisualizerOverallStats({ - indexPatternTitle, - query, - timeFieldName, - earliest, - latest, - samplerShardSize, - aggregatableFields, - nonAggregatableFields, - }: { - indexPatternTitle: string; - query: any; - timeFieldName?: string; - earliest?: number; - latest?: number; - samplerShardSize?: number; - aggregatableFields: string[]; - nonAggregatableFields: string[]; - }) { - const body = JSON.stringify({ + }: { + indexPatternTitle: string; + query: any; + timeFieldName?: string; + earliest?: number; + latest?: number; + samplerShardSize?: number; + interval?: string; + fields?: FieldRequestConfig[]; + maxExamples?: number; + }) { + const body = JSON.stringify({ + query, + timeFieldName, + earliest, + latest, + samplerShardSize, + interval, + fields, + maxExamples, + }); + + return httpService.http({ + path: `${basePath()}/data_visualizer/get_field_stats/${indexPatternTitle}`, + method: 'POST', + body, + }); + }, + + getVisualizerOverallStats({ + indexPatternTitle, query, timeFieldName, earliest, @@ -493,204 +504,230 @@ export const ml = { samplerShardSize, aggregatableFields, nonAggregatableFields, - }); - - return http({ - path: `${basePath()}/data_visualizer/get_overall_stats/${indexPatternTitle}`, - method: 'POST', - body, - }); - }, - - /** - * Gets a list of calendars - * @param obj - * @returns {Promise} - */ - calendars(obj?: { calendarId?: CalendarId; calendarIds?: CalendarId[] }) { - const { calendarId, calendarIds } = obj || {}; - let calendarIdsPathComponent = ''; - if (calendarId) { - calendarIdsPathComponent = `/${calendarId}`; - } else if (calendarIds) { - calendarIdsPathComponent = `/${calendarIds.join(',')}`; - } - return http({ - path: `${basePath()}/calendars${calendarIdsPathComponent}`, - method: 'GET', - }); - }, - - addCalendar(obj: Calendar) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/calendars`, - method: 'PUT', - body, - }); - }, - - updateCalendar(obj: UpdateCalendar) { - const calendarId = obj && obj.calendarId ? `/${obj.calendarId}` : ''; - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/calendars${calendarId}`, - method: 'PUT', - body, - }); - }, - - deleteCalendar({ calendarId }: { calendarId?: string }) { - return http({ - path: `${basePath()}/calendars/${calendarId}`, - method: 'DELETE', - }); - }, - - mlNodeCount() { - return http<{ count: number }>({ - path: `${basePath()}/ml_node_count`, - method: 'GET', - }); - }, - - mlInfo() { - return http({ - path: `${basePath()}/info`, - method: 'GET', - }); - }, - - calculateModelMemoryLimit$({ - analysisConfig, - indexPattern, - query, - timeFieldName, - earliestMs, - latestMs, - }: { - analysisConfig: AnalysisConfig; - indexPattern: string; - query: any; - timeFieldName: string; - earliestMs: number; - latestMs: number; - }) { - const body = JSON.stringify({ + }: { + indexPatternTitle: string; + query: any; + timeFieldName?: string; + earliest?: number; + latest?: number; + samplerShardSize?: number; + aggregatableFields: string[]; + nonAggregatableFields: string[]; + }) { + const body = JSON.stringify({ + query, + timeFieldName, + earliest, + latest, + samplerShardSize, + aggregatableFields, + nonAggregatableFields, + }); + + return httpService.http({ + path: `${basePath()}/data_visualizer/get_overall_stats/${indexPatternTitle}`, + method: 'POST', + body, + }); + }, + + /** + * Gets a list of calendars + * @param obj + * @returns {Promise} + */ + calendars(obj?: { calendarId?: CalendarId; calendarIds?: CalendarId[] }) { + const { calendarId, calendarIds } = obj || {}; + let calendarIdsPathComponent = ''; + if (calendarId) { + calendarIdsPathComponent = `/${calendarId}`; + } else if (calendarIds) { + calendarIdsPathComponent = `/${calendarIds.join(',')}`; + } + return httpService.http({ + path: `${basePath()}/calendars${calendarIdsPathComponent}`, + method: 'GET', + }); + }, + + addCalendar(obj: Calendar) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/calendars`, + method: 'PUT', + body, + }); + }, + + updateCalendar(obj: UpdateCalendar) { + const calendarId = obj && obj.calendarId ? `/${obj.calendarId}` : ''; + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/calendars${calendarId}`, + method: 'PUT', + body, + }); + }, + + deleteCalendar({ calendarId }: { calendarId?: string }) { + return httpService.http({ + path: `${basePath()}/calendars/${calendarId}`, + method: 'DELETE', + }); + }, + + mlNodeCount() { + return httpService.http<{ count: number }>({ + path: `${basePath()}/ml_node_count`, + method: 'GET', + }); + }, + + mlInfo() { + return httpService.http({ + path: `${basePath()}/info`, + method: 'GET', + }); + }, + + calculateModelMemoryLimit$({ analysisConfig, indexPattern, query, timeFieldName, earliestMs, latestMs, - }); - - return http$<{ modelMemoryLimit: string }>({ - path: `${basePath()}/validate/calculate_model_memory_limit`, - method: 'POST', - body, - }); - }, - - getCardinalityOfFields({ - index, - fieldNames, - query, - timeFieldName, - earliestMs, - latestMs, - }: { - index: string; - fieldNames: string[]; - query: any; - timeFieldName: string; - earliestMs: number; - latestMs: number; - }) { - const body = JSON.stringify({ index, fieldNames, query, timeFieldName, earliestMs, latestMs }); - - return http({ - path: `${basePath()}/fields_service/field_cardinality`, - method: 'POST', - body, - }); - }, - - getTimeFieldRange({ - index, - timeFieldName, - query, - }: { - index: string; - timeFieldName?: string; - query: any; - }) { - const body = JSON.stringify({ index, timeFieldName, query }); - - return http({ - path: `${basePath()}/fields_service/time_field_range`, - method: 'POST', - body, - }); - }, - - esSearch(obj: any) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/es_search`, - method: 'POST', - body, - }); - }, - - esSearch$(obj: any) { - const body = JSON.stringify(obj); - return http$({ - path: `${basePath()}/es_search`, - method: 'POST', - body, - }); - }, - - getIndices() { - const tempBasePath = '/api'; - return http>({ - path: `${tempBasePath}/index_management/indices`, - method: 'GET', - }); - }, - - getModelSnapshots(jobId: string, snapshotId?: string) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots${ - snapshotId !== undefined ? `/${snapshotId}` : '' - }`, - }); - }, - - updateModelSnapshot( - jobId: string, - snapshotId: string, - body: { description?: string; retain?: boolean } - ) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}/_update`, - method: 'POST', - body: JSON.stringify(body), - }); - }, - - deleteModelSnapshot(jobId: string, snapshotId: string) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}`, - method: 'DELETE', - }); - }, - - annotations, - dataFrameAnalytics, - filters, - results, - jobs, - fileDatavisualizer, -}; + }: { + analysisConfig: AnalysisConfig; + indexPattern: string; + query: any; + timeFieldName: string; + earliestMs: number; + latestMs: number; + }) { + const body = JSON.stringify({ + analysisConfig, + indexPattern, + query, + timeFieldName, + earliestMs, + latestMs, + }); + + return httpService.http$<{ modelMemoryLimit: string }>({ + path: `${basePath()}/validate/calculate_model_memory_limit`, + method: 'POST', + body, + }); + }, + + getCardinalityOfFields({ + index, + fieldNames, + query, + timeFieldName, + earliestMs, + latestMs, + }: { + index: string; + fieldNames: string[]; + query: any; + timeFieldName: string; + earliestMs: number; + latestMs: number; + }) { + const body = JSON.stringify({ + index, + fieldNames, + query, + timeFieldName, + earliestMs, + latestMs, + }); + + return httpService.http({ + path: `${basePath()}/fields_service/field_cardinality`, + method: 'POST', + body, + }); + }, + + getTimeFieldRange({ + index, + timeFieldName, + query, + }: { + index: string; + timeFieldName?: string; + query: any; + }) { + const body = JSON.stringify({ index, timeFieldName, query }); + + return httpService.http({ + path: `${basePath()}/fields_service/time_field_range`, + method: 'POST', + body, + }); + }, + + esSearch(obj: any) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/es_search`, + method: 'POST', + body, + }); + }, + + esSearch$(obj: any) { + const body = JSON.stringify(obj); + return httpService.http$({ + path: `${basePath()}/es_search`, + method: 'POST', + body, + }); + }, + + getIndices() { + const tempBasePath = '/api'; + return httpService.http>({ + path: `${tempBasePath}/index_management/indices`, + method: 'GET', + }); + }, + + getModelSnapshots(jobId: string, snapshotId?: string) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots${ + snapshotId !== undefined ? `/${snapshotId}` : '' + }`, + }); + }, + + updateModelSnapshot( + jobId: string, + snapshotId: string, + body: { description?: string; retain?: boolean } + ) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}/_update`, + method: 'POST', + body: JSON.stringify(body), + }); + }, + + deleteModelSnapshot(jobId: string, snapshotId: string) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}`, + method: 'DELETE', + }); + }, + + annotations, + dataFrameAnalytics, + filters, + results: resultsApiProvider(httpService), + jobs, + fileDatavisualizer, + }; +} diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts index 830e6fab4163a3..521fd306847eba 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts @@ -5,14 +5,14 @@ */ // Service for obtaining data for the ML Results dashboards. -import { http, http$ } from '../http_service'; +import { HttpService } from '../http_service'; import { basePath } from './index'; import { JobId } from '../../../../common/types/anomaly_detection_jobs'; import { PartitionFieldsDefinition } from '../results_service/result_service_rx'; -export const results = { +export const resultsApiProvider = (httpService: HttpService) => ({ getAnomaliesTableData( jobIds: string[], criteriaFields: string[], @@ -40,7 +40,7 @@ export const results = { influencersFilterQuery, }); - return http$({ + return httpService.http$({ path: `${basePath()}/results/anomalies_table_data`, method: 'POST', body, @@ -53,7 +53,7 @@ export const results = { earliestMs, latestMs, }); - return http({ + return httpService.http({ path: `${basePath()}/results/max_anomaly_score`, method: 'POST', body, @@ -62,7 +62,7 @@ export const results = { getCategoryDefinition(jobId: string, categoryId: string) { const body = JSON.stringify({ jobId, categoryId }); - return http({ + return httpService.http({ path: `${basePath()}/results/category_definition`, method: 'POST', body, @@ -75,7 +75,7 @@ export const results = { categoryIds, maxExamples, }); - return http({ + return httpService.http({ path: `${basePath()}/results/category_examples`, method: 'POST', body, @@ -90,10 +90,10 @@ export const results = { latestMs: number ) { const body = JSON.stringify({ jobId, searchTerm, criteriaFields, earliestMs, latestMs }); - return http$({ + return httpService.http$({ path: `${basePath()}/results/partition_fields_values`, method: 'POST', body, }); }, -}; +}); diff --git a/x-pack/plugins/ml/public/application/services/results_service/index.ts b/x-pack/plugins/ml/public/application/services/results_service/index.ts index cc02248f4d5a9a..6c508422e70632 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/index.ts @@ -4,47 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - getMetricData, - getModelPlotOutput, - getRecordsForCriteria, - getScheduledEventsByBucket, - fetchPartitionFieldsValues, -} from './result_service_rx'; -import { - getEventDistributionData, - getEventRateData, - getInfluencerValueMaxScoreByTime, - getOverallBucketScores, - getRecordInfluencers, - getRecordMaxScoreByTime, - getRecords, - getRecordsForDetector, - getRecordsForInfluencer, - getScoresByBucket, - getTopInfluencers, - getTopInfluencerValues, -} from './results_service'; - -export const mlResultsService = { - getScoresByBucket, - getScheduledEventsByBucket, - getTopInfluencers, - getTopInfluencerValues, - getOverallBucketScores, - getInfluencerValueMaxScoreByTime, - getRecordInfluencers, - getRecordsForInfluencer, - getRecordsForDetector, - getRecords, - getRecordsForCriteria, - getMetricData, - getEventRateData, - getEventDistributionData, - getModelPlotOutput, - getRecordMaxScoreByTime, - fetchPartitionFieldsValues, -}; +import { resultsServiceRxProvider } from './result_service_rx'; +import { resultsServiceProvider } from './results_service'; +import { ml, MlApiServices } from '../ml_api_service'; export type MlResultsService = typeof mlResultsService; @@ -57,3 +19,12 @@ export interface CriteriaField { fieldName: string; fieldValue: any; } + +export const mlResultsService = mlResultsServiceProvider(ml); + +export function mlResultsServiceProvider(mlApiServices: MlApiServices) { + return { + ...resultsServiceProvider(mlApiServices), + ...resultsServiceRxProvider(mlApiServices), + }; +} diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index a21d0caaedd339..1bcbd8dbcdd639 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -17,7 +17,7 @@ import _ from 'lodash'; import { Dictionary } from '../../../../common/types/common'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; import { JobId } from '../../../../common/types/anomaly_detection_jobs'; -import { ml } from '../ml_api_service'; +import { MlApiServices } from '../ml_api_service'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns'; import { CriteriaField } from './index'; @@ -46,524 +46,528 @@ export type PartitionFieldsDefinition = { [field in FieldTypes]: FieldDefinition; }; -export function getMetricData( - index: string, - entityFields: any[], - query: object | undefined, - metricFunction: string, // ES aggregation name - metricFieldName: string, - timeFieldName: string, - earliestMs: number, - latestMs: number, - interval: string -): Observable { - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, entity fields, - // plus any additional supplied query. - const shouldCriteria: object[] = []; - const mustCriteria: object[] = [ - { - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ...(query ? [query] : []), - ]; - - entityFields.forEach((entity) => { - if (entity.fieldValue.length !== 0) { - mustCriteria.push({ - term: { - [entity.fieldName]: entity.fieldValue, - }, - }); - } else { - // Add special handling for blank entity field values, checking for either - // an empty string or the field not existing. - shouldCriteria.push({ - bool: { - must: [ - { - term: { - [entity.fieldName]: '', - }, - }, - ], - }, - }); - shouldCriteria.push({ - bool: { - must_not: [ - { - exists: { field: entity.fieldName }, - }, - ], - }, - }); - } - }); - - const body: any = { - query: { - bool: { - must: mustCriteria, - }, - }, - size: 0, - _source: { - excludes: [], - }, - aggs: { - byTime: { - date_histogram: { - field: timeFieldName, - interval, - min_doc_count: 0, - }, - }, - }, - }; - - if (shouldCriteria.length > 0) { - body.query.bool.should = shouldCriteria; - body.query.bool.minimum_should_match = shouldCriteria.length / 2; - } - - if (metricFieldName !== undefined && metricFieldName !== '') { - body.aggs.byTime.aggs = {}; - - const metricAgg: any = { - [metricFunction]: { - field: metricFieldName, - }, - }; - - if (metricFunction === 'percentiles') { - metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; - } - body.aggs.byTime.aggs.metric = metricAgg; - } - - return ml.esSearch$({ index, body }).pipe( - map((resp: any) => { - const obj: MetricData = { success: true, results: {} }; - const dataByTime = resp?.aggregations?.byTime?.buckets ?? []; - dataByTime.forEach((dataForTime: any) => { - if (metricFunction === 'count') { - obj.results[dataForTime.key] = dataForTime.doc_count; - } else { - const value = dataForTime?.metric?.value; - const values = dataForTime?.metric?.values; - if (dataForTime.doc_count === 0) { - obj.results[dataForTime.key] = null; - } else if (value !== undefined) { - obj.results[dataForTime.key] = value; - } else if (values !== undefined) { - // Percentiles agg currently returns NaN rather than null when none of the docs in the - // bucket contain the field used in the aggregation - // (see elasticsearch issue https://github.com/elastic/elasticsearch/issues/29066). - // Store as null, so values can be handled in the same manner downstream as other aggs - // (min, mean, max) which return null. - const medianValues = values[ML_MEDIAN_PERCENTS]; - obj.results[dataForTime.key] = !isNaN(medianValues) ? medianValues : null; - } else { - obj.results[dataForTime.key] = null; - } - } - }); - - return obj; - }) - ); -} - export interface ModelPlotOutput extends ResultResponse { results: Record; } -export function getModelPlotOutput( - jobId: string, - detectorIndex: number, - criteriaFields: any[], - earliestMs: number, - latestMs: number, - interval: string, - aggType?: { min: any; max: any } -): Observable { - const obj: ModelPlotOutput = { - success: true, - results: {}, - }; +export interface RecordsForCriteria extends ResultResponse { + records: any[]; +} - // if an aggType object has been passed in, use it. - // otherwise default to min and max aggs for the upper and lower bounds - const modelAggs = - aggType === undefined - ? { max: 'max', min: 'min' } - : { - max: aggType.max, - min: aggType.min, - }; +export interface ScheduledEventsByBucket extends ResultResponse { + events: Record; +} - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the job ID and time range. - const mustCriteria: object[] = [ - { - term: { job_id: jobId }, - }, - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - // Add in term queries for each of the specified criteria. - _.each(criteriaFields, (criteria) => { - mustCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - }); - - // Add criteria for the detector index. Results from jobs created before 6.1 will not - // contain a detector_index field, so use a should criteria with a 'not exists' check. - const shouldCriteria = [ - { - term: { detector_index: detectorIndex }, - }, - { - bool: { - must_not: [ - { - exists: { field: 'detector_index' }, +export function resultsServiceRxProvider(mlApiServices: MlApiServices) { + return { + getMetricData( + index: string, + entityFields: any[], + query: object | undefined, + metricFunction: string, // ES aggregation name + metricFieldName: string, + timeFieldName: string, + earliestMs: number, + latestMs: number, + interval: string + ): Observable { + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, entity fields, + // plus any additional supplied query. + const shouldCriteria: object[] = []; + const mustCriteria: object[] = [ + { + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, }, - ], - }, - }, - ]; + }, + ...(query ? [query] : []), + ]; + + entityFields.forEach((entity) => { + if (entity.fieldValue.length !== 0) { + mustCriteria.push({ + term: { + [entity.fieldName]: entity.fieldValue, + }, + }); + } else { + // Add special handling for blank entity field values, checking for either + // an empty string or the field not existing. + shouldCriteria.push({ + bool: { + must: [ + { + term: { + [entity.fieldName]: '', + }, + }, + ], + }, + }); + shouldCriteria.push({ + bool: { + must_not: [ + { + exists: { field: entity.fieldName }, + }, + ], + }, + }); + } + }); - return ml - .esSearch$({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { + const body: any = { query: { bool: { - filter: [ - { - query_string: { - query: 'result_type:model_plot', - analyze_wildcard: true, - }, - }, - { - bool: { - must: mustCriteria, - should: shouldCriteria, - minimum_should_match: 1, - }, - }, - ], + must: mustCriteria, }, }, + size: 0, + _source: { + excludes: [], + }, aggs: { - times: { + byTime: { date_histogram: { - field: 'timestamp', + field: timeFieldName, interval, min_doc_count: 0, }, - aggs: { - actual: { - avg: { - field: 'actual', - }, - }, - modelUpper: { - [modelAggs.max]: { - field: 'model_upper', - }, - }, - modelLower: { - [modelAggs.min]: { - field: 'model_lower', - }, - }, - }, }, }, - }, - }) - .pipe( - map((resp) => { - const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); - _.each(aggregationsByTime, (dataForTime: any) => { - const time = dataForTime.key; - const modelUpper: number | undefined = _.get(dataForTime, ['modelUpper', 'value']); - const modelLower: number | undefined = _.get(dataForTime, ['modelLower', 'value']); - const actual = _.get(dataForTime, ['actual', 'value']); - - obj.results[time] = { - actual, - modelUpper: - modelUpper === undefined || isFinite(modelUpper) === false ? null : modelUpper, - modelLower: - modelLower === undefined || isFinite(modelLower) === false ? null : modelLower, - }; - }); + }; - return obj; - }) - ); -} + if (shouldCriteria.length > 0) { + body.query.bool.should = shouldCriteria; + body.query.bool.minimum_should_match = shouldCriteria.length / 2; + } -export interface RecordsForCriteria extends ResultResponse { - records: any[]; -} + if (metricFieldName !== undefined && metricFieldName !== '') { + body.aggs.byTime.aggs = {}; -// Queries Elasticsearch to obtain the record level results matching the given criteria, -// for the specified job(s), time range, and record score threshold. -// criteriaFields parameter must be an array, with each object in the array having 'fieldName' -// 'fieldValue' properties. -// Pass an empty array or ['*'] to search over all job IDs. -export function getRecordsForCriteria( - jobIds: string[] | undefined, - criteriaFields: CriteriaField[], - threshold: any, - earliestMs: number, - latestMs: number, - maxResults: number | undefined -): Observable { - const obj: RecordsForCriteria = { success: true, records: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, record score, plus any specified job IDs. - const boolCriteria: any[] = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ]; + const metricAgg: any = { + [metricFunction]: { + field: metricFieldName, + }, + }; - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; + if (metricFunction === 'percentiles') { + metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; + } + body.aggs.byTime.aggs.metric = metricAgg; } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - // Add in term queries for each of the specified criteria. - _.each(criteriaFields, (criteria) => { - boolCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - }); - - return ml - .esSearch$({ - index: ML_RESULTS_INDEX_PATTERN, - rest_total_hits_as_int: true, - size: maxResults !== undefined ? maxResults : 100, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .pipe( - map((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit: any) => { - obj.records.push(hit._source); + + return mlApiServices.esSearch$({ index, body }).pipe( + map((resp: any) => { + const obj: MetricData = { success: true, results: {} }; + const dataByTime = resp?.aggregations?.byTime?.buckets ?? []; + dataByTime.forEach((dataForTime: any) => { + if (metricFunction === 'count') { + obj.results[dataForTime.key] = dataForTime.doc_count; + } else { + const value = dataForTime?.metric?.value; + const values = dataForTime?.metric?.values; + if (dataForTime.doc_count === 0) { + obj.results[dataForTime.key] = null; + } else if (value !== undefined) { + obj.results[dataForTime.key] = value; + } else if (values !== undefined) { + // Percentiles agg currently returns NaN rather than null when none of the docs in the + // bucket contain the field used in the aggregation + // (see elasticsearch issue https://github.com/elastic/elasticsearch/issues/29066). + // Store as null, so values can be handled in the same manner downstream as other aggs + // (min, mean, max) which return null. + const medianValues = values[ML_MEDIAN_PERCENTS]; + obj.results[dataForTime.key] = !isNaN(medianValues) ? medianValues : null; + } else { + obj.results[dataForTime.key] = null; + } + } }); - } - return obj; - }) - ); -} -export interface ScheduledEventsByBucket extends ResultResponse { - events: Record; -} + return obj; + }) + ); + }, -// Obtains a list of scheduled events by job ID and time. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a events property, which will only -// contains keys for jobs which have scheduled events for the specified time range. -export function getScheduledEventsByBucket( - jobIds: string[] | undefined, - earliestMs: number, - latestMs: number, - interval: string, - maxJobs: number, - maxEvents: number -): Observable { - const obj: ScheduledEventsByBucket = { - success: true, - events: {}, - }; + getModelPlotOutput( + jobId: string, + detectorIndex: number, + criteriaFields: any[], + earliestMs: number, + latestMs: number, + interval: string, + aggType?: { min: any; max: any } + ): Observable { + const obj: ModelPlotOutput = { + success: true, + results: {}, + }; + + // if an aggType object has been passed in, use it. + // otherwise default to min and max aggs for the upper and lower bounds + const modelAggs = + aggType === undefined + ? { max: 'max', min: 'min' } + : { + max: aggType.max, + min: aggType.min, + }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the job ID and time range. + const mustCriteria: object[] = [ + { + term: { job_id: jobId }, + }, + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + ]; - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria: any[] = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', + // Add in term queries for each of the specified criteria. + _.each(criteriaFields, (criteria) => { + mustCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); + }); + + // Add criteria for the detector index. Results from jobs created before 6.1 will not + // contain a detector_index field, so use a should criteria with a 'not exists' check. + const shouldCriteria = [ + { + term: { detector_index: detectorIndex }, }, - }, - }, - { - exists: { field: 'scheduled_events' }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - jobIdFilterStr += `${i > 0 ? ' OR ' : ''}job_id:${jobId}`; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - return ml - .esSearch$({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { + { bool: { - filter: [ + must_not: [ { - query_string: { - query: 'result_type:bucket', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, + exists: { field: 'detector_index' }, }, ], }, }, - aggs: { - jobs: { - terms: { - field: 'job_id', - min_doc_count: 1, - size: maxJobs, + ]; + + return mlApiServices + .esSearch$({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:model_plot', + analyze_wildcard: true, + }, + }, + { + bool: { + must: mustCriteria, + should: shouldCriteria, + minimum_should_match: 1, + }, + }, + ], + }, }, aggs: { times: { date_histogram: { field: 'timestamp', interval, - min_doc_count: 1, + min_doc_count: 0, }, aggs: { - events: { - terms: { - field: 'scheduled_events', - size: maxEvents, + actual: { + avg: { + field: 'actual', + }, + }, + modelUpper: { + [modelAggs.max]: { + field: 'model_upper', + }, + }, + modelLower: { + [modelAggs.min]: { + field: 'model_lower', }, }, }, }, }, }, + }) + .pipe( + map((resp) => { + const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); + _.each(aggregationsByTime, (dataForTime: any) => { + const time = dataForTime.key; + const modelUpper: number | undefined = _.get(dataForTime, ['modelUpper', 'value']); + const modelLower: number | undefined = _.get(dataForTime, ['modelLower', 'value']); + const actual = _.get(dataForTime, ['actual', 'value']); + + obj.results[time] = { + actual, + modelUpper: + modelUpper === undefined || isFinite(modelUpper) === false ? null : modelUpper, + modelLower: + modelLower === undefined || isFinite(modelLower) === false ? null : modelLower, + }; + }); + + return obj; + }) + ); + }, + + // Queries Elasticsearch to obtain the record level results matching the given criteria, + // for the specified job(s), time range, and record score threshold. + // criteriaFields parameter must be an array, with each object in the array having 'fieldName' + // 'fieldValue' properties. + // Pass an empty array or ['*'] to search over all job IDs. + getRecordsForCriteria( + jobIds: string[] | undefined, + criteriaFields: CriteriaField[], + threshold: any, + earliestMs: number, + latestMs: number, + maxResults: number | undefined + ): Observable { + const obj: RecordsForCriteria = { success: true, records: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, record score, plus any specified job IDs. + const boolCriteria: any[] = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, - }) - .pipe( - map((resp) => { - const dataByJobId = _.get(resp, ['aggregations', 'jobs', 'buckets'], []); - _.each(dataByJobId, (dataForJob: any) => { - const jobId: string = dataForJob.key; - const resultsForTime: Record = {}; - const dataByTime = _.get(dataForJob, ['times', 'buckets'], []); - _.each(dataByTime, (dataForTime: any) => { - const time: string = dataForTime.key; - const events: object[] = _.get(dataForTime, ['events', 'buckets']); - resultsForTime[time] = _.map(events, 'key'); - }); - obj.events[jobId] = resultsForTime; + { + range: { + record_score: { + gte: threshold, + }, + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, }); + } - return obj; - }) - ); -} + // Add in term queries for each of the specified criteria. + _.each(criteriaFields, (criteria) => { + boolCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); + }); -export function fetchPartitionFieldsValues( - jobId: JobId, - searchTerm: Dictionary, - criteriaFields: Array<{ fieldName: string; fieldValue: any }>, - earliestMs: number, - latestMs: number -) { - return ml.results.fetchPartitionFieldsValues( - jobId, - searchTerm, - criteriaFields, - earliestMs, - latestMs - ); + return mlApiServices + .esSearch$({ + index: ML_RESULTS_INDEX_PATTERN, + rest_total_hits_as_int: true, + size: maxResults !== undefined ? maxResults : 100, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + sort: [{ record_score: { order: 'desc' } }], + }, + }) + .pipe( + map((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit: any) => { + obj.records.push(hit._source); + }); + } + return obj; + }) + ); + }, + + // Obtains a list of scheduled events by job ID and time. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a events property, which will only + // contains keys for jobs which have scheduled events for the specified time range. + getScheduledEventsByBucket( + jobIds: string[] | undefined, + earliestMs: number, + latestMs: number, + interval: string, + maxJobs: number, + maxEvents: number + ): Observable { + const obj: ScheduledEventsByBucket = { + success: true, + events: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria: any[] = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + exists: { field: 'scheduled_events' }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + jobIdFilterStr += `${i > 0 ? ' OR ' : ''}job_id:${jobId}`; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + return mlApiServices + .esSearch$({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:bucket', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + aggs: { + jobs: { + terms: { + field: 'job_id', + min_doc_count: 1, + size: maxJobs, + }, + aggs: { + times: { + date_histogram: { + field: 'timestamp', + interval, + min_doc_count: 1, + }, + aggs: { + events: { + terms: { + field: 'scheduled_events', + size: maxEvents, + }, + }, + }, + }, + }, + }, + }, + }, + }) + .pipe( + map((resp) => { + const dataByJobId = _.get(resp, ['aggregations', 'jobs', 'buckets'], []); + _.each(dataByJobId, (dataForJob: any) => { + const jobId: string = dataForJob.key; + const resultsForTime: Record = {}; + const dataByTime = _.get(dataForJob, ['times', 'buckets'], []); + _.each(dataByTime, (dataForTime: any) => { + const time: string = dataForTime.key; + const events: object[] = _.get(dataForTime, ['events', 'buckets']); + resultsForTime[time] = _.map(events, 'key'); + }); + obj.events[jobId] = resultsForTime; + }); + + return obj; + }) + ); + }, + + fetchPartitionFieldsValues( + jobId: JobId, + searchTerm: Dictionary, + criteriaFields: Array<{ fieldName: string; fieldValue: any }>, + earliestMs: number, + latestMs: number + ) { + return mlApiServices.results.fetchPartitionFieldsValues( + jobId, + searchTerm, + criteriaFields, + earliestMs, + latestMs + ); + }, + }; } diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts index 4af08994432bdb..1b2c01ab73fcef 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts @@ -4,43 +4,49 @@ * you may not use this file except in compliance with the Elastic License. */ -export function getScoresByBucket( - jobIds: string[], - earliestMs: number, - latestMs: number, - interval: string | number, - maxResults: number -): Promise; -export function getTopInfluencers(): Promise; -export function getTopInfluencerValues(): Promise; -export function getOverallBucketScores( - jobIds: any, - topN: any, - earliestMs: any, - latestMs: any, - interval?: any -): Promise; -export function getInfluencerValueMaxScoreByTime( - jobIds: string[], - influencerFieldName: string, - influencerFieldValues: string[], - earliestMs: number, - latestMs: number, - interval: string, - maxResults: number, - influencersFilterQuery: any -): Promise; -export function getRecordInfluencers(): Promise; -export function getRecordsForInfluencer(): Promise; -export function getRecordsForDetector(): Promise; -export function getRecords(): Promise; -export function getEventRateData( - index: string, - query: any, - timeFieldName: string, - earliestMs: number, - latestMs: number, - interval: string | number -): Promise; -export function getEventDistributionData(): Promise; -export function getRecordMaxScoreByTime(): Promise; +import { MlApiServices } from '../ml_api_service'; + +export function resultsServiceProvider( + mlApiServices: MlApiServices +): { + getScoresByBucket( + jobIds: string[], + earliestMs: number, + latestMs: number, + interval: string | number, + maxResults: number + ): Promise; + getTopInfluencers(): Promise; + getTopInfluencerValues(): Promise; + getOverallBucketScores( + jobIds: any, + topN: any, + earliestMs: any, + latestMs: any, + interval?: any + ): Promise; + getInfluencerValueMaxScoreByTime( + jobIds: string[], + influencerFieldName: string, + influencerFieldValues: string[], + earliestMs: number, + latestMs: number, + interval: string, + maxResults: number, + influencersFilterQuery: any + ): Promise; + getRecordInfluencers(): Promise; + getRecordsForInfluencer(): Promise; + getRecordsForDetector(): Promise; + getRecords(): Promise; + getEventRateData( + index: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number, + interval: string | number + ): Promise; + getEventDistributionData(): Promise; + getRecordMaxScoreByTime(): Promise; +}; diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.js b/x-pack/plugins/ml/public/application/services/results_service/results_service.js index 4fccc4d789370c..9e3fed189b6f48 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.js +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.js @@ -4,1322 +4,1331 @@ * you may not use this file except in compliance with the Elastic License. */ -// Service for carrying out Elasticsearch queries to obtain data for the -// Ml Results dashboards. import _ from 'lodash'; -// import d3 from 'd3'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; import { escapeForElasticsearchQuery } from '../../util/string_utils'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns'; -import { ml } from '../ml_api_service'; - -// Obtains the maximum bucket anomaly scores by job ID and time. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a results property, with a key for job -// which has results for the specified time range. -export function getScoresByBucket(jobIds, earliestMs, latestMs, interval, maxResults) { - return new Promise((resolve, reject) => { - const obj = { - success: true, - results: {}, - }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:bucket', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, +/** + * Service for carrying out Elasticsearch queries to obtain data for the Ml Results dashboards. + */ +export function resultsServiceProvider(mlApiServices) { + const SAMPLER_TOP_TERMS_SHARD_SIZE = 20000; + const ENTITY_AGGREGATION_SIZE = 10; + const AGGREGATION_MIN_DOC_COUNT = 1; + const CARDINALITY_PRECISION_THRESHOLD = 100; + + return { + // Obtains the maximum bucket anomaly scores by job ID and time. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a results property, with a key for job + // which has results for the specified time range. + getScoresByBucket(jobIds, earliestMs, latestMs, interval, maxResults) { + return new Promise((resolve, reject) => { + const obj = { + success: true, + results: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, - ], + }, }, - }, - aggs: { - jobId: { - terms: { - field: 'job_id', - size: maxResults !== undefined ? maxResults : 5, - order: { - anomalyScore: 'desc', - }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, }, - aggs: { - anomalyScore: { - max: { - field: 'anomaly_score', + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:bucket', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, }, - byTime: { - date_histogram: { - field: 'timestamp', - interval: interval, - min_doc_count: 1, - extended_bounds: { - min: earliestMs, - max: latestMs, + aggs: { + jobId: { + terms: { + field: 'job_id', + size: maxResults !== undefined ? maxResults : 5, + order: { + anomalyScore: 'desc', + }, }, - }, - aggs: { - anomalyScore: { - max: { - field: 'anomaly_score', + aggs: { + anomalyScore: { + max: { + field: 'anomaly_score', + }, + }, + byTime: { + date_histogram: { + field: 'timestamp', + interval: interval, + min_doc_count: 1, + extended_bounds: { + min: earliestMs, + max: latestMs, + }, + }, + aggs: { + anomalyScore: { + max: { + field: 'anomaly_score', + }, + }, + }, }, }, }, }, }, + }) + .then((resp) => { + const dataByJobId = _.get(resp, ['aggregations', 'jobId', 'buckets'], []); + _.each(dataByJobId, (dataForJob) => { + const jobId = dataForJob.key; + + const resultsForTime = {}; + + const dataByTime = _.get(dataForJob, ['byTime', 'buckets'], []); + _.each(dataByTime, (dataForTime) => { + const value = _.get(dataForTime, ['anomalyScore', 'value']); + if (value !== undefined) { + const time = dataForTime.key; + resultsForTime[time] = _.get(dataForTime, ['anomalyScore', 'value']); + } + }); + obj.results[jobId] = resultsForTime; + }); + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Obtains the top influencers, by maximum influencer score, for the specified index, time range and job ID(s). + // Pass an empty array or ['*'] to search over all job IDs. + // An optional array of influencers may be supplied, with each object in the array having 'fieldName' + // and 'fieldValue' properties, to limit data to the supplied list of influencers. + // Returned response contains an influencers property, with a key for each of the influencer field names, + // whose value is an array of objects containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. + getTopInfluencers( + jobIds, + earliestMs, + latestMs, + maxFieldValues = 10, + influencers = [], + influencersFilterQuery + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, influencers: {} }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, - }, - }) - .then((resp) => { - const dataByJobId = _.get(resp, ['aggregations', 'jobId', 'buckets'], []); - _.each(dataByJobId, (dataForJob) => { - const jobId = dataForJob.key; - - const resultsForTime = {}; - - const dataByTime = _.get(dataForJob, ['byTime', 'buckets'], []); - _.each(dataByTime, (dataForTime) => { - const value = _.get(dataForTime, ['anomalyScore', 'value']); - if (value !== undefined) { - const time = dataForTime.key; - resultsForTime[time] = _.get(dataForTime, ['anomalyScore', 'value']); + { + range: { + influencer_score: { + gt: 0, + }, + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; }); - obj.results[jobId] = resultsForTime; - }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} + if (influencersFilterQuery !== undefined) { + boolCriteria.push(influencersFilterQuery); + } -// Obtains the top influencers, by maximum influencer score, for the specified index, time range and job ID(s). -// Pass an empty array or ['*'] to search over all job IDs. -// An optional array of influencers may be supplied, with each object in the array having 'fieldName' -// and 'fieldValue' properties, to limit data to the supplied list of influencers. -// Returned response contains an influencers property, with a key for each of the influencer field names, -// whose value is an array of objects containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. -export function getTopInfluencers( - jobIds, - earliestMs, - latestMs, - maxFieldValues = 10, - influencers = [], - influencersFilterQuery -) { - return new Promise((resolve, reject) => { - const obj = { success: true, influencers: {} }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - influencer_score: { - gt: 0, - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; + // Add a should query to filter for each of the specified influencers. + if (influencers.length > 0) { + boolCriteria.push({ + bool: { + should: influencers.map((influencer) => { + return { + bool: { + must: [ + { term: { influencer_field_name: influencer.fieldName } }, + { term: { influencer_field_value: influencer.fieldValue } }, + ], + }, + }; + }), + minimum_should_match: 1, + }, + }); } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - if (influencersFilterQuery !== undefined) { - boolCriteria.push(influencersFilterQuery); - } - - // Add a should query to filter for each of the specified influencers. - if (influencers.length > 0) { - boolCriteria.push({ - bool: { - should: influencers.map((influencer) => { - return { - bool: { - must: [ - { term: { influencer_field_name: influencer.fieldName } }, - { term: { influencer_field_value: influencer.fieldValue } }, - ], - }, - }; - }), - minimum_should_match: 1, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:influencer', - analyze_wildcard: false, - }, - }, - { + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - aggs: { - influencerFieldNames: { - terms: { - field: 'influencer_field_name', - size: 5, - order: { - maxAnomalyScore: 'desc', - }, - }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', + filter: [ + { + query_string: { + query: 'result_type:influencer', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, }, - influencerFieldValues: { - terms: { - field: 'influencer_field_value', - size: maxFieldValues, - order: { - maxAnomalyScore: 'desc', - }, - }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', + aggs: { + influencerFieldNames: { + terms: { + field: 'influencer_field_name', + size: 5, + order: { + maxAnomalyScore: 'desc', }, }, - sumAnomalyScore: { - sum: { - field: 'influencer_score', + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + influencerFieldValues: { + terms: { + field: 'influencer_field_value', + size: maxFieldValues, + order: { + maxAnomalyScore: 'desc', + }, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + sumAnomalyScore: { + sum: { + field: 'influencer_score', + }, + }, + }, }, }, }, }, }, - }, - }, - }, - }) - .then((resp) => { - const fieldNameBuckets = _.get( - resp, - ['aggregations', 'influencerFieldNames', 'buckets'], - [] - ); - _.each(fieldNameBuckets, (nameBucket) => { - const fieldName = nameBucket.key; - const fieldValues = []; - - const fieldValueBuckets = _.get(nameBucket, ['influencerFieldValues', 'buckets'], []); - _.each(fieldValueBuckets, (valueBucket) => { - const fieldValueResult = { - influencerFieldValue: valueBucket.key, - maxAnomalyScore: valueBucket.maxAnomalyScore.value, - sumAnomalyScore: valueBucket.sumAnomalyScore.value, - }; - fieldValues.push(fieldValueResult); - }); - - obj.influencers[fieldName] = fieldValues; - }); - - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} + }) + .then((resp) => { + const fieldNameBuckets = _.get( + resp, + ['aggregations', 'influencerFieldNames', 'buckets'], + [] + ); + _.each(fieldNameBuckets, (nameBucket) => { + const fieldName = nameBucket.key; + const fieldValues = []; + + const fieldValueBuckets = _.get(nameBucket, ['influencerFieldValues', 'buckets'], []); + _.each(fieldValueBuckets, (valueBucket) => { + const fieldValueResult = { + influencerFieldValue: valueBucket.key, + maxAnomalyScore: valueBucket.maxAnomalyScore.value, + sumAnomalyScore: valueBucket.sumAnomalyScore.value, + }; + fieldValues.push(fieldValueResult); + }); + + obj.influencers[fieldName] = fieldValues; + }); -// Obtains the top influencer field values, by maximum anomaly score, for a -// particular index, field name and job ID(s). -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a results property, which is an array of objects -// containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. -export function getTopInfluencerValues( - jobIds, - influencerFieldName, - earliestMs, - latestMs, - maxResults -) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( - influencerFieldName - )}`, - analyze_wildcard: false, - }, + }, + + // Obtains the top influencer field values, by maximum anomaly score, for a + // particular index, field name and job ID(s). + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a results property, which is an array of objects + // containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. + getTopInfluencerValues(jobIds, influencerFieldName, earliestMs, latestMs, maxResults) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, - { - bool: { - must: boolCriteria, - }, - }, - ], + }, }, - }, - aggs: { - influencerFieldValues: { - terms: { - field: 'influencer_field_value', - size: maxResults !== undefined ? maxResults : 2, - order: { - maxAnomalyScore: 'desc', - }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( + influencerFieldName + )}`, + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, }, - sumAnomalyScore: { - sum: { - field: 'influencer_score', + aggs: { + influencerFieldValues: { + terms: { + field: 'influencer_field_value', + size: maxResults !== undefined ? maxResults : 2, + order: { + maxAnomalyScore: 'desc', + }, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + sumAnomalyScore: { + sum: { + field: 'influencer_score', + }, + }, + }, }, }, }, - }, - }, - }, - }) - .then((resp) => { - const buckets = _.get(resp, ['aggregations', 'influencerFieldValues', 'buckets'], []); - _.each(buckets, (bucket) => { - const result = { - influencerFieldValue: bucket.key, - maxAnomalyScore: bucket.maxAnomalyScore.value, - sumAnomalyScore: bucket.sumAnomalyScore.value, - }; - obj.results.push(result); - }); + }) + .then((resp) => { + const buckets = _.get(resp, ['aggregations', 'influencerFieldValues', 'buckets'], []); + _.each(buckets, (bucket) => { + const result = { + influencerFieldValue: bucket.key, + maxAnomalyScore: bucket.maxAnomalyScore.value, + sumAnomalyScore: bucket.sumAnomalyScore.value, + }; + obj.results.push(result); + }); - resolve(obj); - }) - .catch((resp) => { - reject(resp); + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - }); -} - -// Obtains the overall bucket scores for the specified job ID(s). -// Pass ['*'] to search over all job IDs. -// Returned response contains a results property as an object of max score by time. -export function getOverallBucketScores(jobIds, topN, earliestMs, latestMs, interval) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: {} }; - - ml.overallBuckets({ - jobId: jobIds, - topN: topN, - bucketSpan: interval, - start: earliestMs, - end: latestMs, - }) - .then((resp) => { - const dataByTime = _.get(resp, ['overall_buckets'], []); - _.each(dataByTime, (dataForTime) => { - const value = _.get(dataForTime, ['overall_score']); - if (value !== undefined) { - obj.results[dataForTime.timestamp] = value; - } - }); + }, + + // Obtains the overall bucket scores for the specified job ID(s). + // Pass ['*'] to search over all job IDs. + // Returned response contains a results property as an object of max score by time. + getOverallBucketScores(jobIds, topN, earliestMs, latestMs, interval) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: {} }; + + mlApiServices + .overallBuckets({ + jobId: jobIds, + topN: topN, + bucketSpan: interval, + start: earliestMs, + end: latestMs, + }) + .then((resp) => { + const dataByTime = _.get(resp, ['overall_buckets'], []); + _.each(dataByTime, (dataForTime) => { + const value = _.get(dataForTime, ['overall_score']); + if (value !== undefined) { + obj.results[dataForTime.timestamp] = value; + } + }); - resolve(obj); - }) - .catch((resp) => { - reject(resp); + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - }); -} - -// Obtains the maximum score by influencer_field_value and by time for the specified job ID(s) -// (pass an empty array or ['*'] to search over all job IDs), and specified influencer field -// values (pass an empty array to search over all field values). -// Returned response contains a results property with influencer field values keyed -// against max score by time. -export function getInfluencerValueMaxScoreByTime( - jobIds, - influencerFieldName, - influencerFieldValues, - earliestMs, - latestMs, - interval, - maxResults, - influencersFilterQuery -) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: {} }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', + }, + + // Obtains the maximum score by influencer_field_value and by time for the specified job ID(s) + // (pass an empty array or ['*'] to search over all job IDs), and specified influencer field + // values (pass an empty array to search over all field values). + // Returned response contains a results property with influencer field values keyed + // against max score by time. + getInfluencerValueMaxScoreByTime( + jobIds, + influencerFieldName, + influencerFieldValues, + earliestMs, + latestMs, + interval, + maxResults, + influencersFilterQuery + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: {} }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, - }, - { - range: { - influencer_score: { - gt: 0, + { + range: { + influencer_score: { + gt: 0, + }, + }, }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += `job_id:${jobId}`; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } + ]; - if (influencersFilterQuery !== undefined) { - boolCriteria.push(influencersFilterQuery); - } + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += `job_id:${jobId}`; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } - if (influencerFieldValues && influencerFieldValues.length > 0) { - let influencerFilterStr = ''; - _.each(influencerFieldValues, (value, i) => { - if (i > 0) { - influencerFilterStr += ' OR '; + if (influencersFilterQuery !== undefined) { + boolCriteria.push(influencersFilterQuery); } - if (value.trim().length > 0) { - influencerFilterStr += `influencer_field_value:${escapeForElasticsearchQuery(value)}`; - } else { - // Wrap whitespace influencer field values in quotes for the query_string query. - influencerFilterStr += `influencer_field_value:"${value}"`; + + if (influencerFieldValues && influencerFieldValues.length > 0) { + let influencerFilterStr = ''; + _.each(influencerFieldValues, (value, i) => { + if (i > 0) { + influencerFilterStr += ' OR '; + } + if (value.trim().length > 0) { + influencerFilterStr += `influencer_field_value:${escapeForElasticsearchQuery(value)}`; + } else { + // Wrap whitespace influencer field values in quotes for the query_string query. + influencerFilterStr += `influencer_field_value:"${value}"`; + } + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: influencerFilterStr, + }, + }); } - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: influencerFilterStr, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( - influencerFieldName - )}`, - analyze_wildcard: false, + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( + influencerFieldName + )}`, + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + aggs: { + influencerFieldValues: { + terms: { + field: 'influencer_field_value', + size: maxResults !== undefined ? maxResults : 10, + order: { + maxAnomalyScore: 'desc', + }, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + byTime: { + date_histogram: { + field: 'timestamp', + interval, + min_doc_count: 1, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + }, + }, + }, }, }, - { + }, + }) + .then((resp) => { + const fieldValueBuckets = _.get( + resp, + ['aggregations', 'influencerFieldValues', 'buckets'], + [] + ); + _.each(fieldValueBuckets, (valueBucket) => { + const fieldValue = valueBucket.key; + const fieldValues = {}; + + const timeBuckets = _.get(valueBucket, ['byTime', 'buckets'], []); + _.each(timeBuckets, (timeBucket) => { + const time = timeBucket.key; + const score = timeBucket.maxAnomalyScore.value; + fieldValues[time] = score; + }); + + obj.results[fieldValue] = fieldValues; + }); + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Queries Elasticsearch to obtain record level results containing the influencers + // for the specified job(s), record score threshold, and time range. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a records property, with each record containing + // only the fields job_id, detector_index, record_score and influencers. + getRecordInfluencers(jobIds, threshold, earliestMs, latestMs, maxResults) { + return new Promise((resolve, reject) => { + const obj = { success: true, records: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the existence of the nested influencers field, time range, + // record score, plus any specified job IDs. + const boolCriteria = [ + { + nested: { + path: 'influencers', + query: { bool: { - must: boolCriteria, + must: [ + { + exists: { field: 'influencers' }, + }, + ], }, }, - ], + }, }, - }, - aggs: { - influencerFieldValues: { - terms: { - field: 'influencer_field_value', - size: maxResults !== undefined ? maxResults : 10, - order: { - maxAnomalyScore: 'desc', + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', - }, + }, + { + range: { + record_score: { + gte: threshold, }, - byTime: { - date_histogram: { - field: 'timestamp', - interval, - min_doc_count: 1, - }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: maxResults !== undefined ? maxResults : 100, + rest_total_hits_as_int: true, + body: { + _source: ['job_id', 'detector_index', 'influencers', 'record_score'], + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, }, - }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, }, + sort: [{ record_score: { order: 'desc' } }], }, - }, - }, - }, - }) - .then((resp) => { - const fieldValueBuckets = _.get( - resp, - ['aggregations', 'influencerFieldValues', 'buckets'], - [] - ); - _.each(fieldValueBuckets, (valueBucket) => { - const fieldValue = valueBucket.key; - const fieldValues = {}; - - const timeBuckets = _.get(valueBucket, ['byTime', 'buckets'], []); - _.each(timeBuckets, (timeBucket) => { - const time = timeBucket.key; - const score = timeBucket.maxAnomalyScore.value; - fieldValues[time] = score; + }) + .then((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit) => { + obj.records.push(hit._source); + }); + } + resolve(obj); + }) + .catch((resp) => { + reject(resp); }); + }); + }, + + // Queries Elasticsearch to obtain the record level results containing the specified influencer(s), + // for the specified job(s), time range, and record score threshold. + // influencers parameter must be an array, with each object in the array having 'fieldName' + // 'fieldValue' properties. The influencer array uses 'should' for the nested bool query, + // so this returns record level results which have at least one of the influencers. + // Pass an empty array or ['*'] to search over all job IDs. + getRecordsForInfluencer( + jobIds, + influencers, + threshold, + earliestMs, + latestMs, + maxResults, + influencersFilterQuery + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, records: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, record score, plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + range: { + record_score: { + gte: threshold, + }, + }, + }, + ]; - obj.results[fieldValue] = fieldValues; - }); + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} + if (influencersFilterQuery !== undefined) { + boolCriteria.push(influencersFilterQuery); + } -// Queries Elasticsearch to obtain record level results containing the influencers -// for the specified job(s), record score threshold, and time range. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a records property, with each record containing -// only the fields job_id, detector_index, record_score and influencers. -export function getRecordInfluencers(jobIds, threshold, earliestMs, latestMs, maxResults) { - return new Promise((resolve, reject) => { - const obj = { success: true, records: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the existence of the nested influencers field, time range, - // record score, plus any specified job IDs. - const boolCriteria = [ - { - nested: { - path: 'influencers', - query: { + // Add a nested query to filter for each of the specified influencers. + if (influencers.length > 0) { + boolCriteria.push({ bool: { - must: [ - { - exists: { field: 'influencers' }, + should: influencers.map((influencer) => { + return { + nested: { + path: 'influencers', + query: { + bool: { + must: [ + { + match: { + 'influencers.influencer_field_name': influencer.fieldName, + }, + }, + { + match: { + 'influencers.influencer_field_values': influencer.fieldValue, + }, + }, + ], + }, + }, + }, + }; + }), + minimum_should_match: 1, + }, + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: maxResults !== undefined ? maxResults : 100, + rest_total_hits_as_int: true, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, - ], + }, + sort: [{ record_score: { order: 'desc' } }], + }, + }) + .then((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit) => { + obj.records.push(hit._source); + }); + } + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Queries Elasticsearch to obtain the record level results for the specified job and detector, + // time range, record score threshold, and whether to only return results containing influencers. + // An additional, optional influencer field name and value may also be provided. + getRecordsForDetector( + jobId, + detectorIndex, + checkForInfluencers, + influencerFieldName, + influencerFieldValue, + threshold, + earliestMs, + latestMs, + maxResults + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, records: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, record score, plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, }, }, - }, - }, - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', + { + term: { job_id: jobId }, }, - }, - }, - { - range: { - record_score: { - gte: threshold, + { + term: { detector_index: detectorIndex }, }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: maxResults !== undefined ? maxResults : 100, - rest_total_hits_as_int: true, - body: { - _source: ['job_id', 'detector_index', 'influencers', 'record_score'], - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, + { + range: { + record_score: { + gte: threshold, }, - { + }, + }, + ]; + + // Add a nested query to filter for the specified influencer field name and value. + if (influencerFieldName && influencerFieldValue) { + boolCriteria.push({ + nested: { + path: 'influencers', + query: { bool: { - must: boolCriteria, + must: [ + { + match: { + 'influencers.influencer_field_name': influencerFieldName, + }, + }, + { + match: { + 'influencers.influencer_field_values': influencerFieldValue, + }, + }, + ], }, }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .then((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit) => { - obj.records.push(hit._source); + }, }); - } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} - -// Queries Elasticsearch to obtain the record level results containing the specified influencer(s), -// for the specified job(s), time range, and record score threshold. -// influencers parameter must be an array, with each object in the array having 'fieldName' -// 'fieldValue' properties. The influencer array uses 'should' for the nested bool query, -// so this returns record level results which have at least one of the influencers. -// Pass an empty array or ['*'] to search over all job IDs. -export function getRecordsForInfluencer( - jobIds, - influencers, - threshold, - earliestMs, - latestMs, - maxResults, - influencersFilterQuery -) { - return new Promise((resolve, reject) => { - const obj = { success: true, records: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, record score, plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - if (influencersFilterQuery !== undefined) { - boolCriteria.push(influencersFilterQuery); - } - - // Add a nested query to filter for each of the specified influencers. - if (influencers.length > 0) { - boolCriteria.push({ - bool: { - should: influencers.map((influencer) => { - return { + } else { + if (checkForInfluencers === true) { + boolCriteria.push({ nested: { path: 'influencers', query: { bool: { must: [ { - match: { - 'influencers.influencer_field_name': influencer.fieldName, - }, - }, - { - match: { - 'influencers.influencer_field_values': influencer.fieldValue, - }, + exists: { field: 'influencers' }, }, ], }, }, }, - }; - }), - minimum_should_match: 1, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: maxResults !== undefined ? maxResults : 100, - rest_total_hits_as_int: true, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .then((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit) => { - obj.records.push(hit._source); - }); + }); + } } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} -// Queries Elasticsearch to obtain the record level results for the specified job and detector, -// time range, record score threshold, and whether to only return results containing influencers. -// An additional, optional influencer field name and value may also be provided. -export function getRecordsForDetector( - jobId, - detectorIndex, - checkForInfluencers, - influencerFieldName, - influencerFieldValue, - threshold, - earliestMs, - latestMs, - maxResults -) { - return new Promise((resolve, reject) => { - const obj = { success: true, records: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, record score, plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - term: { job_id: jobId }, - }, - { - term: { detector_index: detectorIndex }, - }, - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ]; - - // Add a nested query to filter for the specified influencer field name and value. - if (influencerFieldName && influencerFieldValue) { - boolCriteria.push({ - nested: { - path: 'influencers', - query: { - bool: { - must: [ - { - match: { - 'influencers.influencer_field_name': influencerFieldName, - }, - }, - { - match: { - 'influencers.influencer_field_values': influencerFieldValue, - }, + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: maxResults !== undefined ? maxResults : 100, + rest_total_hits_as_int: true, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, - ], + }, + sort: [{ record_score: { order: 'desc' } }], }, - }, - }, + }) + .then((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit) => { + obj.records.push(hit._source); + }); + } + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - } else { - if (checkForInfluencers === true) { - boolCriteria.push({ - nested: { - path: 'influencers', - query: { - bool: { - must: [ - { - exists: { field: 'influencers' }, - }, - ], + }, + + // Queries Elasticsearch to obtain all the record level results for the specified job(s), time range, + // and record score threshold. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a records property, which is an array of the matching results. + getRecords(jobIds, threshold, earliestMs, latestMs, maxResults) { + return this.getRecordsForInfluencer(jobIds, [], threshold, earliestMs, latestMs, maxResults); + }, + + // Queries Elasticsearch to obtain event rate data i.e. the count + // of documents over time. + // index can be a String, or String[], of index names to search. + // Extra query object can be supplied, or pass null if no additional query. + // Returned response contains a results property, which is an object + // of document counts against time (epoch millis). + getEventRateData(index, query, timeFieldName, earliestMs, latestMs, interval) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: {} }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, entity fields, + // plus any additional supplied query. + const mustCriteria = [ + { + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, }, }, - }); - } - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: maxResults !== undefined ? maxResults : 100, - rest_total_hits_as_int: true, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, + ]; + + if (query) { + mustCriteria.push(query); + } + + mlApiServices + .esSearch({ + index, + rest_total_hits_as_int: true, + size: 0, + body: { + query: { + bool: { + must: mustCriteria, }, }, - { - bool: { - must: boolCriteria, + _source: { + excludes: [], + }, + aggs: { + eventRate: { + date_histogram: { + field: timeFieldName, + interval: interval, + min_doc_count: 0, + extended_bounds: { + min: earliestMs, + max: latestMs, + }, + }, }, }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .then((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit) => { - obj.records.push(hit._source); + }, + }) + .then((resp) => { + const dataByTimeBucket = _.get(resp, ['aggregations', 'eventRate', 'buckets'], []); + _.each(dataByTimeBucket, (dataForTime) => { + const time = dataForTime.key; + obj.results[time] = dataForTime.doc_count; + }); + obj.total = resp.hits.total; + + resolve(obj); + }) + .catch((resp) => { + reject(resp); }); - } - resolve(obj); - }) - .catch((resp) => { - reject(resp); }); - }); -} - -// Queries Elasticsearch to obtain all the record level results for the specified job(s), time range, -// and record score threshold. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a records property, which is an array of the matching results. -export function getRecords(jobIds, threshold, earliestMs, latestMs, maxResults) { - return this.getRecordsForInfluencer(jobIds, [], threshold, earliestMs, latestMs, maxResults); -} + }, -// Queries Elasticsearch to obtain event rate data i.e. the count -// of documents over time. -// index can be a String, or String[], of index names to search. -// Extra query object can be supplied, or pass null if no additional query. -// Returned response contains a results property, which is an object -// of document counts against time (epoch millis). -export function getEventRateData(index, query, timeFieldName, earliestMs, latestMs, interval) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: {} }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, entity fields, - // plus any additional supplied query. - const mustCriteria = [ - { - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (query) { - mustCriteria.push(query); - } + // Queries Elasticsearch to obtain event distribution i.e. the count + // of entities over time. + // index can be a String, or String[], of index names to search. + // Extra query object can be supplied, or pass null if no additional query. + // Returned response contains a results property, which is an object + // of document counts against time (epoch millis). - ml.esSearch({ + getEventDistributionData( index, - rest_total_hits_as_int: true, - size: 0, - body: { - query: { - bool: { - must: mustCriteria, - }, - }, - _source: { - excludes: [], - }, - aggs: { - eventRate: { - date_histogram: { - field: timeFieldName, - interval: interval, - min_doc_count: 0, - extended_bounds: { - min: earliestMs, - max: latestMs, - }, + splitField, + filterField = null, + query, + metricFunction, // ES aggregation name + metricFieldName, + timeFieldName, + earliestMs, + latestMs, + interval + ) { + return new Promise((resolve, reject) => { + if (splitField === undefined) { + return resolve([]); + } + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, entity fields, + // plus any additional supplied query. + const mustCriteria = []; + + mustCriteria.push({ + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, }, - }, - }, - }) - .then((resp) => { - const dataByTimeBucket = _.get(resp, ['aggregations', 'eventRate', 'buckets'], []); - _.each(dataByTimeBucket, (dataForTime) => { - const time = dataForTime.key; - obj.results[time] = dataForTime.doc_count; }); - obj.total = resp.hits.total; - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} + if (query) { + mustCriteria.push(query); + } -// Queries Elasticsearch to obtain event distribution i.e. the count -// of entities over time. -// index can be a String, or String[], of index names to search. -// Extra query object can be supplied, or pass null if no additional query. -// Returned response contains a results property, which is an object -// of document counts against time (epoch millis). -const SAMPLER_TOP_TERMS_SHARD_SIZE = 20000; -const ENTITY_AGGREGATION_SIZE = 10; -const AGGREGATION_MIN_DOC_COUNT = 1; -const CARDINALITY_PRECISION_THRESHOLD = 100; -export function getEventDistributionData( - index, - splitField, - filterField = null, - query, - metricFunction, // ES aggregation name - metricFieldName, - timeFieldName, - earliestMs, - latestMs, - interval -) { - return new Promise((resolve, reject) => { - if (splitField === undefined) { - return resolve([]); - } - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, entity fields, - // plus any additional supplied query. - const mustCriteria = []; - - mustCriteria.push({ - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }); - - if (query) { - mustCriteria.push(query); - } - - if (filterField !== null) { - mustCriteria.push({ - term: { - [filterField.fieldName]: filterField.fieldValue, - }, - }); - } - - const body = { - query: { - // using function_score and random_score to get a random sample of documents. - // otherwise all documents would have the same score and the sampler aggregation - // would pick the first N documents instead of a random set. - function_score: { - query: { - bool: { - must: mustCriteria, + if (filterField !== null) { + mustCriteria.push({ + term: { + [filterField.fieldName]: filterField.fieldValue, }, - }, - functions: [ - { - random_score: { - // static seed to get same randomized results on every request - seed: 10, - field: '_seq_no', + }); + } + + const body = { + query: { + // using function_score and random_score to get a random sample of documents. + // otherwise all documents would have the same score and the sampler aggregation + // would pick the first N documents instead of a random set. + function_score: { + query: { + bool: { + must: mustCriteria, + }, }, + functions: [ + { + random_score: { + // static seed to get same randomized results on every request + seed: 10, + field: '_seq_no', + }, + }, + ], }, - ], - }, - }, - size: 0, - _source: { - excludes: [], - }, - aggs: { - sample: { - sampler: { - shard_size: SAMPLER_TOP_TERMS_SHARD_SIZE, + }, + size: 0, + _source: { + excludes: [], }, aggs: { - byTime: { - date_histogram: { - field: timeFieldName, - interval: interval, - min_doc_count: AGGREGATION_MIN_DOC_COUNT, + sample: { + sampler: { + shard_size: SAMPLER_TOP_TERMS_SHARD_SIZE, }, aggs: { - entities: { - terms: { - field: splitField.fieldName, - size: ENTITY_AGGREGATION_SIZE, + byTime: { + date_histogram: { + field: timeFieldName, + interval: interval, min_doc_count: AGGREGATION_MIN_DOC_COUNT, }, + aggs: { + entities: { + terms: { + field: splitField.fieldName, + size: ENTITY_AGGREGATION_SIZE, + min_doc_count: AGGREGATION_MIN_DOC_COUNT, + }, + }, + }, }, }, }, }, - }, - }, - }; - - if (metricFieldName !== undefined && metricFieldName !== '') { - body.aggs.sample.aggs.byTime.aggs.entities.aggs = {}; - - const metricAgg = { - [metricFunction]: { - field: metricFieldName, - }, - }; - - if (metricFunction === 'percentiles') { - metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; - } - - if (metricFunction === 'cardinality') { - metricAgg[metricFunction].precision_threshold = CARDINALITY_PRECISION_THRESHOLD; - } - body.aggs.sample.aggs.byTime.aggs.entities.aggs.metric = metricAgg; - } - - ml.esSearch({ - index, - body, - rest_total_hits_as_int: true, - }) - .then((resp) => { - // Because of the sampling, results of metricFunctions which use sum or count - // can be significantly skewed. Taking into account totalHits we calculate a - // a factor to normalize results for these metricFunctions. - const totalHits = _.get(resp, ['hits', 'total'], 0); - const successfulShards = _.get(resp, ['_shards', 'successful'], 0); - - let normalizeFactor = 1; - if (totalHits > successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE) { - normalizeFactor = totalHits / (successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE); + }; + + if (metricFieldName !== undefined && metricFieldName !== '') { + body.aggs.sample.aggs.byTime.aggs.entities.aggs = {}; + + const metricAgg = { + [metricFunction]: { + field: metricFieldName, + }, + }; + + if (metricFunction === 'percentiles') { + metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; + } + + if (metricFunction === 'cardinality') { + metricAgg[metricFunction].precision_threshold = CARDINALITY_PRECISION_THRESHOLD; + } + body.aggs.sample.aggs.byTime.aggs.entities.aggs.metric = metricAgg; } - const dataByTime = _.get(resp, ['aggregations', 'sample', 'byTime', 'buckets'], []); - const data = dataByTime.reduce((d, dataForTime) => { - const date = +dataForTime.key; - const entities = _.get(dataForTime, ['entities', 'buckets'], []); - entities.forEach((entity) => { - let value = metricFunction === 'count' ? entity.doc_count : entity.metric.value; - - if ( - metricFunction === 'count' || - metricFunction === 'cardinality' || - metricFunction === 'sum' - ) { - value = value * normalizeFactor; + mlApiServices + .esSearch({ + index, + body, + rest_total_hits_as_int: true, + }) + .then((resp) => { + // Because of the sampling, results of metricFunctions which use sum or count + // can be significantly skewed. Taking into account totalHits we calculate a + // a factor to normalize results for these metricFunctions. + const totalHits = _.get(resp, ['hits', 'total'], 0); + const successfulShards = _.get(resp, ['_shards', 'successful'], 0); + + let normalizeFactor = 1; + if (totalHits > successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE) { + normalizeFactor = totalHits / (successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE); } - d.push({ - date, - entity: entity.key, - value, - }); + const dataByTime = _.get(resp, ['aggregations', 'sample', 'byTime', 'buckets'], []); + const data = dataByTime.reduce((d, dataForTime) => { + const date = +dataForTime.key; + const entities = _.get(dataForTime, ['entities', 'buckets'], []); + entities.forEach((entity) => { + let value = metricFunction === 'count' ? entity.doc_count : entity.metric.value; + + if ( + metricFunction === 'count' || + metricFunction === 'cardinality' || + metricFunction === 'sum' + ) { + value = value * normalizeFactor; + } + + d.push({ + date, + entity: entity.key, + value, + }); + }); + return d; + }, []); + resolve(data); + }) + .catch((resp) => { + reject(resp); }); - return d; - }, []); - resolve(data); - }) - .catch((resp) => { - reject(resp); }); - }); -} - -// Queries Elasticsearch to obtain the max record score over time for the specified job, -// criteria, time range, and aggregation interval. -// criteriaFields parameter must be an array, with each object in the array having 'fieldName' -// 'fieldValue' properties. -export function getRecordMaxScoreByTime(jobId, criteriaFields, earliestMs, latestMs, interval) { - return new Promise((resolve, reject) => { - const obj = { - success: true, - results: {}, - }; - - // Build the criteria to use in the bool filter part of the request. - const mustCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { term: { job_id: jobId } }, - ]; - - _.each(criteriaFields, (criteria) => { - mustCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - }); - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: true, - }, + }, + + // Queries Elasticsearch to obtain the max record score over time for the specified job, + // criteria, time range, and aggregation interval. + // criteriaFields parameter must be an array, with each object in the array having 'fieldName' + // 'fieldValue' properties. + getRecordMaxScoreByTime(jobId, criteriaFields, earliestMs, latestMs, interval) { + return new Promise((resolve, reject) => { + const obj = { + success: true, + results: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + const mustCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, - { + }, + }, + { term: { job_id: jobId } }, + ]; + + _.each(criteriaFields, (criteria) => { + mustCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); + }); + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { bool: { - must: mustCriteria, + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: true, + }, + }, + { + bool: { + must: mustCriteria, + }, + }, + ], }, }, - ], - }, - }, - aggs: { - times: { - date_histogram: { - field: 'timestamp', - interval: interval, - min_doc_count: 1, - }, - aggs: { - recordScore: { - max: { - field: 'record_score', + aggs: { + times: { + date_histogram: { + field: 'timestamp', + interval: interval, + min_doc_count: 1, + }, + aggs: { + recordScore: { + max: { + field: 'record_score', + }, + }, + }, }, }, }, - }, - }, - }, - }) - .then((resp) => { - const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); - _.each(aggregationsByTime, (dataForTime) => { - const time = dataForTime.key; - obj.results[time] = { - score: _.get(dataForTime, ['recordScore', 'value']), - }; - }); + }) + .then((resp) => { + const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); + _.each(aggregationsByTime, (dataForTime) => { + const time = dataForTime.key; + obj.results[time] = { + score: _.get(dataForTime, ['recordScore', 'value']), + }; + }); - resolve(obj); - }) - .catch((resp) => { - reject(resp); + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - }); + }, + }; } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx index b53b08e5f6146f..b4b25db452bdb7 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx @@ -7,6 +7,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { CoreStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; import { Subject } from 'rxjs'; import { Embeddable, @@ -25,12 +26,19 @@ import { RefreshInterval, TimeRange, } from '../../../../../../src/plugins/data/common'; +import { SwimlaneType } from '../../application/explorer/explorer_constants'; export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane'; +export const getDefaultPanelTitle = (jobIds: JobId[]) => + i18n.translate('xpack.ml.swimlaneEmbeddable.title', { + defaultMessage: 'ML anomaly swimlane for {jobIds}', + values: { jobIds: jobIds.join(', ') }, + }); + export interface AnomalySwimlaneEmbeddableCustomInput { jobIds: JobId[]; - swimlaneType: string; + swimlaneType: SwimlaneType; viewBy?: string; limit?: number; @@ -43,9 +51,12 @@ export interface AnomalySwimlaneEmbeddableCustomInput { export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput; -export interface AnomalySwimlaneEmbeddableOutput extends EmbeddableOutput { +export type AnomalySwimlaneEmbeddableOutput = EmbeddableOutput & + AnomalySwimlaneEmbeddableCustomOutput; + +export interface AnomalySwimlaneEmbeddableCustomOutput { jobIds: JobId[]; - swimlaneType: string; + swimlaneType: SwimlaneType; viewBy?: string; limit?: number; } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts index e86d738d8b8093..09091b21e49b6d 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts @@ -23,8 +23,9 @@ import { MlStartDependencies } from '../../plugin'; import { HttpService } from '../../application/services/http_service'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { ExplorerService } from '../../application/services/explorer_service'; -import { mlResultsService } from '../../application/services/results_service'; +import { mlResultsServiceProvider } from '../../application/services/results_service'; import { resolveAnomalySwimlaneUserInput } from './anomaly_swimlane_setup_flyout'; +import { mlApiServicesProvider } from '../../application/services/ml_api_service'; export class AnomalySwimlaneEmbeddableFactory implements EmbeddableFactoryDefinition { @@ -64,8 +65,7 @@ export class AnomalySwimlaneEmbeddableFactory const explorerService = new ExplorerService( pluginsStart.data.query.timefilter.timefilter, coreStart.uiSettings, - // TODO mlResultsService to use DI - mlResultsService + mlResultsServiceProvider(mlApiServicesProvider(httpService)) ); return [coreStart, pluginsStart, { anomalyDetectorService, explorerService }]; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx index 00d47c0d897c74..4c93b9ef232391 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx @@ -20,7 +20,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; +import { SWIMLANE_TYPE, SwimlaneType } from '../../application/explorer/explorer_constants'; import { AnomalySwimlaneEmbeddableInput } from './anomaly_swimlane_embeddable'; export interface AnomalySwimlaneInitializerProps { @@ -31,7 +31,7 @@ export interface AnomalySwimlaneInitializerProps { >; onCreate: (swimlaneProps: { panelTitle: string; - swimlaneType: string; + swimlaneType: SwimlaneType; viewBy?: string; limit?: number; }) => void; @@ -51,8 +51,8 @@ export const AnomalySwimlaneInitializer: FC = ( initialInput, }) => { const [panelTitle, setPanelTitle] = useState(defaultTitle); - const [swimlaneType, setSwimlaneType] = useState( - (initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL) as SWIMLANE_TYPE + const [swimlaneType, setSwimlaneType] = useState( + initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL ); const [viewBySwimlaneFieldName, setViewBySwimlaneFieldName] = useState(initialInput?.viewBy); const [limit, setLimit] = useState(initialInput?.limit ?? 5); @@ -135,7 +135,7 @@ export const AnomalySwimlaneInitializer: FC = ( })} options={swimlaneTypeOptions} idSelected={swimlaneType} - onChange={(id) => setSwimlaneType(id as SWIMLANE_TYPE)} + onChange={(id) => setSwimlaneType(id as SwimlaneType)} /> diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx index 83f9833109bf45..54f50d2d3da326 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { IUiSettingsClient, OverlayStart } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; @@ -14,7 +13,10 @@ import { AnomalySwimlaneInitializer } from './anomaly_swimlane_initializer'; import { JobSelectorFlyout } from '../../application/components/job_selector/job_selector_flyout'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { getInitialGroupsMap } from '../../application/components/job_selector/job_selector'; -import { AnomalySwimlaneEmbeddableInput } from './anomaly_swimlane_embeddable'; +import { + AnomalySwimlaneEmbeddableInput, + getDefaultPanelTitle, +} from './anomaly_swimlane_embeddable'; export async function resolveAnomalySwimlaneUserInput( { @@ -52,12 +54,7 @@ export async function resolveAnomalySwimlaneUserInput( reject(); }} onSelectionConfirmed={async ({ jobIds, groups }) => { - const title = - input?.title ?? - i18n.translate('xpack.ml.swimlaneEmbeddable.title', { - defaultMessage: 'ML anomaly swimlane for {jobIds}', - values: { jobIds: jobIds.join(', ') }, - }); + const title = input?.title ?? getDefaultPanelTitle(jobIds); const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx index e5d8584683c55b..0bba9b59f7bf73 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useState } from 'react'; +import React, { FC, useCallback, useState } from 'react'; import { EuiCallOut, EuiFlexGroup, @@ -28,6 +28,7 @@ import { } from './anomaly_swimlane_embeddable'; import { MlTooltipComponent } from '../../application/components/chart_tooltip'; import { useSwimlaneInputResolver } from './swimlane_input_resolver'; +import { SwimlaneType } from '../../application/explorer/explorer_constants'; const RESIZE_THROTTLE_TIME_MS = 500; @@ -54,10 +55,13 @@ export const ExplorerSwimlaneContainer: FC = ({ chartWidth ); - const onResize = throttle((e: { width: number; height: number }) => { - const labelWidth = 200; - setChartWidth(e.width - labelWidth); - }, RESIZE_THROTTLE_TIME_MS); + const onResize = useCallback( + throttle((e: { width: number; height: number }) => { + const labelWidth = 200; + setChartWidth(e.width - labelWidth); + }, RESIZE_THROTTLE_TIME_MS), + [] + ); if (error) { return ( @@ -91,14 +95,14 @@ export const ExplorerSwimlaneContainer: FC = ({ {chartWidth > 0 && swimlaneData && swimlaneType ? ( - + {(tooltipService) => ( )} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts index e704582d5d61ab..3829bbce5e5c96 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts @@ -24,7 +24,7 @@ import { AnomalySwimlaneServices, } from './anomaly_swimlane_embeddable'; import { MlStartDependencies } from '../../plugin'; -import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; +import { SWIMLANE_TYPE, SwimlaneType } from '../../application/explorer/explorer_constants'; import { Filter } from '../../../../../../src/plugins/data/common/es_query/filters'; import { Query } from '../../../../../../src/plugins/data/common/query'; import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public'; @@ -55,7 +55,7 @@ export function useSwimlaneInputResolver( const [{ uiSettings }, , { explorerService, anomalyDetectorService }] = services; const [swimlaneData, setSwimlaneData] = useState(); - const [swimlaneType, setSwimlaneType] = useState(); + const [swimlaneType, setSwimlaneType] = useState(); const [error, setError] = useState(); const chartWidth$ = useMemo(() => new Subject(), []); diff --git a/x-pack/plugins/ml/public/index.ts b/x-pack/plugins/ml/public/index.ts index a9ffb1a5bf5792..5a956651c86d8b 100755 --- a/x-pack/plugins/ml/public/index.ts +++ b/x-pack/plugins/ml/public/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializer } from 'kibana/public'; +import { PluginInitializer, PluginInitializerContext } from 'kibana/public'; import './index.scss'; import { MlPlugin, @@ -19,7 +19,7 @@ export const plugin: PluginInitializer< MlPluginStart, MlSetupDependencies, MlStartDependencies -> = () => new MlPlugin(); +> = (initializerContext: PluginInitializerContext) => new MlPlugin(initializerContext); export { MlPluginSetup, MlPluginStart }; export * from './shared'; diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index fe9f602bc3637e..be2ebb3caa4161 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -5,7 +5,13 @@ */ import { i18n } from '@kbn/i18n'; -import { Plugin, CoreStart, CoreSetup, AppMountParameters } from 'kibana/public'; +import { + Plugin, + CoreStart, + CoreSetup, + AppMountParameters, + PluginInitializerContext, +} from 'kibana/public'; import { ManagementSetup } from 'src/plugins/management/public'; import { SharePluginStart } from 'src/plugins/share/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; @@ -38,9 +44,13 @@ export interface MlSetupDependencies { home: HomePublicPluginSetup; embeddable: EmbeddableSetup; uiActions: UiActionsSetup; + kibanaVersion: string; + share: SharePluginStart; } export class MlPlugin implements Plugin { + constructor(private initializerContext: PluginInitializerContext) {} + setup(core: CoreSetup, pluginsSetup: MlSetupDependencies) { core.application.register({ id: PLUGIN_ID, @@ -53,6 +63,7 @@ export class MlPlugin implements Plugin { category: DEFAULT_APP_CATEGORIES.kibana, mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart] = await core.getStartServices(); + const kibanaVersion = this.initializerContext.env.packageInfo.version; const { renderApp } = await import('./application/app'); return renderApp( coreStart, @@ -67,6 +78,7 @@ export class MlPlugin implements Plugin { home: pluginsSetup.home, embeddable: pluginsSetup.embeddable, uiActions: pluginsSetup.uiActions, + kibanaVersion, }, { element: params.element, diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index e01c608e5f3065..627d1408673be5 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -36,7 +36,12 @@ describe('Security Plugin', () => { ); mockCoreSetup = coreMock.createSetup(); - mockCoreSetup.http.isTlsEnabled = true; + mockCoreSetup.http.getServerInfo.mockReturnValue({ + host: 'localhost', + name: 'kibana', + port: 80, + protocol: 'https', + }); mockClusterClient = elasticsearchServiceMock.createCustomClusterClient(); mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index c8f47aaae7b5d8..a14617c8489ccb 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -118,7 +118,7 @@ export class Plugin { this.initializerContext.config.create>().pipe( map((rawConfig) => createConfig(rawConfig, this.initializerContext.logger.get('config'), { - isTLSEnabled: core.http.isTlsEnabled, + isTLSEnabled: core.http.getServerInfo().protocol === 'https', }) ) ), diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts index 5d170f5a786458..f946b3ad3b39bf 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts @@ -625,7 +625,7 @@ describe('add prepackaged rules schema', () => { const decoded = addPrepackagedRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to "references"']); expect(message.schema).toEqual({}); }); @@ -776,7 +776,9 @@ describe('add prepackaged rules schema', () => { const decoded = addPrepackagedRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "max_signals"', + ]); expect(message.schema).toEqual({}); }); @@ -789,7 +791,7 @@ describe('add prepackaged rules schema', () => { const decoded = addPrepackagedRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to "max_signals"']); expect(message.schema).toEqual({}); }); @@ -837,9 +839,9 @@ describe('add prepackaged rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "0" supplied to ""', - 'Invalid value "1" supplied to ""', - 'Invalid value "2" supplied to ""', + 'Invalid value "0" supplied to "tags"', + 'Invalid value "1" supplied to "tags"', + 'Invalid value "2" supplied to "tags"', ]); expect(message.schema).toEqual({}); }); @@ -871,7 +873,7 @@ describe('add prepackaged rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "framework"', + 'Invalid value "undefined" supplied to "threat,framework"', ]); expect(message.schema).toEqual({}); }); @@ -899,7 +901,7 @@ describe('add prepackaged rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "tactic"', + 'Invalid value "undefined" supplied to "threat,tactic"', ]); expect(message.schema).toEqual({}); }); @@ -925,7 +927,7 @@ describe('add prepackaged rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "technique"', + 'Invalid value "undefined" supplied to "threat,technique"', ]); expect(message.schema).toEqual({}); }); @@ -959,8 +961,8 @@ describe('add prepackaged rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "5" supplied to ""', - 'Invalid value "4" supplied to ""', + 'Invalid value "5" supplied to "false_positives"', + 'Invalid value "4" supplied to "false_positives"', ]); expect(message.schema).toEqual({}); }); @@ -1184,7 +1186,7 @@ describe('add prepackaged rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "group"', + 'Invalid value "undefined" supplied to "actions,group"', ]); expect(message.schema).toEqual({}); }); @@ -1198,7 +1200,9 @@ describe('add prepackaged rules schema', () => { const decoded = addPrepackagedRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "undefined" supplied to "id"']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "actions,id"', + ]); expect(message.schema).toEqual({}); }); @@ -1212,7 +1216,7 @@ describe('add prepackaged rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "action_type_id"', + 'Invalid value "undefined" supplied to "actions,action_type_id"', ]); expect(message.schema).toEqual({}); }); @@ -1227,7 +1231,7 @@ describe('add prepackaged rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "params"', + 'Invalid value "undefined" supplied to "actions,params"', ]); expect(message.schema).toEqual({}); }); @@ -1249,7 +1253,7 @@ describe('add prepackaged rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "action_type_id"', + 'Invalid value "undefined" supplied to "actions,action_type_id"', ]); expect(message.schema).toEqual({}); }); @@ -1323,8 +1327,7 @@ describe('add prepackaged rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - // TODO: Fix/Change the formatErrors to be better able to handle objects - 'Invalid value "[object Object]" supplied to "note"', + 'Invalid value "{"somethingHere":"something else"}" supplied to "note"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_bulk_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_bulk_schema.test.ts index e79dde41752a38..00854f1ed55262 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_bulk_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_bulk_schema.test.ts @@ -250,9 +250,8 @@ describe('create_rules_bulk_schema', () => { const decoded = createRulesBulkSchema.decode(payload); const checked = exactCheck(payload, decoded); const output = foldLeftRight(checked); - // TODO: We should change the formatter used to better print objects expect(formatErrors(output.errors)).toEqual([ - 'Invalid value "[object Object]" supplied to "note"', + 'Invalid value "{"something":"some object"}" supplied to "note"', ]); expect(output.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts index d672d38028902b..a126b833ba4615 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts @@ -614,7 +614,7 @@ describe('create rules schema', () => { const decoded = createRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to "references"']); expect(message.schema).toEqual({}); }); @@ -721,7 +721,9 @@ describe('create rules schema', () => { const decoded = createRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "max_signals"', + ]); expect(message.schema).toEqual({}); }); @@ -734,7 +736,7 @@ describe('create rules schema', () => { const decoded = createRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to "max_signals"']); expect(message.schema).toEqual({}); }); @@ -782,9 +784,9 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "0" supplied to ""', - 'Invalid value "1" supplied to ""', - 'Invalid value "2" supplied to ""', + 'Invalid value "0" supplied to "tags"', + 'Invalid value "1" supplied to "tags"', + 'Invalid value "2" supplied to "tags"', ]); expect(message.schema).toEqual({}); }); @@ -816,7 +818,7 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "framework"', + 'Invalid value "undefined" supplied to "threat,framework"', ]); expect(message.schema).toEqual({}); }); @@ -844,7 +846,7 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "tactic"', + 'Invalid value "undefined" supplied to "threat,tactic"', ]); expect(message.schema).toEqual({}); }); @@ -870,7 +872,7 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "technique"', + 'Invalid value "undefined" supplied to "threat,technique"', ]); expect(message.schema).toEqual({}); }); @@ -902,8 +904,8 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "5" supplied to ""', - 'Invalid value "4" supplied to ""', + 'Invalid value "5" supplied to "false_positives"', + 'Invalid value "4" supplied to "false_positives"', ]); expect(message.schema).toEqual({}); }); @@ -1081,7 +1083,7 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "group"', + 'Invalid value "undefined" supplied to "actions,group"', ]); expect(message.schema).toEqual({}); }); @@ -1095,7 +1097,9 @@ describe('create rules schema', () => { const decoded = createRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "undefined" supplied to "id"']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "actions,id"', + ]); expect(message.schema).toEqual({}); }); @@ -1109,7 +1113,7 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "action_type_id"', + 'Invalid value "undefined" supplied to "actions,action_type_id"', ]); expect(message.schema).toEqual({}); }); @@ -1124,7 +1128,7 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "params"', + 'Invalid value "undefined" supplied to "actions,params"', ]); expect(message.schema).toEqual({}); }); @@ -1146,7 +1150,7 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "action_type_id"', + 'Invalid value "undefined" supplied to "actions,action_type_id"', ]); expect(message.schema).toEqual({}); }); @@ -1198,8 +1202,7 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - // TODO: Fix/Change the formatErrors to be better able to handle objects - 'Invalid value "[object Object]" supplied to "note"', + 'Invalid value "{"somethingHere":"something else"}" supplied to "note"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/export_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/export_rules_schema.test.ts index 3e9799a5ad2f9d..935b4b33081fa8 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/export_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/export_rules_schema.test.ts @@ -34,10 +34,9 @@ describe('create rules schema', () => { const decoded = exportRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - // TODO: Change formatter to display a better value than [object Object] expect(getPaths(left(message.errors))).toEqual([ 'Invalid value "undefined" supplied to "objects"', - 'Invalid value "[object Object]" supplied to ""', + 'Invalid value "{}" supplied to "({| objects: Array<{| rule_id: string |}> |} | null)"', ]); expect(message.schema).toEqual(payload); }); @@ -70,10 +69,9 @@ describe('create rules schema', () => { const decoded = exportRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - // TODO: Change formatter to display a better value than [object Object] expect(getPaths(left(message.errors))).toEqual([ 'Invalid value "undefined" supplied to "objects,rule_id"', - 'Invalid value "[object Object]" supplied to ""', + 'Invalid value "{"objects":[{"id":"4a7ff83d-3055-4bb2-ba68-587b9c6c15a4"}]}" supplied to "({| objects: Array<{| rule_id: string |}> |} | null)"', ]); expect(message.schema).toEqual({}); }); @@ -120,7 +118,9 @@ describe('create rules schema', () => { const decoded = exportRulesQuerySchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "10" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "10" supplied to "file_name"', + ]); expect(message.schema).toEqual({}); }); @@ -151,7 +151,7 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "invalid string" supplied to ""', + 'Invalid value "invalid string" supplied to "exclude_export_details"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts index be2c3e046fe91e..9fe3e95a206217 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts @@ -625,7 +625,7 @@ describe('import rules schema', () => { const decoded = importRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to "references"']); expect(message.schema).toEqual({}); }); @@ -773,7 +773,9 @@ describe('import rules schema', () => { const decoded = importRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "max_signals"', + ]); expect(message.schema).toEqual({}); }); @@ -786,7 +788,7 @@ describe('import rules schema', () => { const decoded = importRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to "max_signals"']); expect(message.schema).toEqual({}); }); @@ -834,9 +836,9 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "0" supplied to ""', - 'Invalid value "1" supplied to ""', - 'Invalid value "2" supplied to ""', + 'Invalid value "0" supplied to "tags"', + 'Invalid value "1" supplied to "tags"', + 'Invalid value "2" supplied to "tags"', ]); expect(message.schema).toEqual({}); }); @@ -868,7 +870,7 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "framework"', + 'Invalid value "undefined" supplied to "threat,framework"', ]); expect(message.schema).toEqual({}); }); @@ -896,7 +898,7 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "tactic"', + 'Invalid value "undefined" supplied to "threat,tactic"', ]); expect(message.schema).toEqual({}); }); @@ -922,7 +924,7 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "technique"', + 'Invalid value "undefined" supplied to "threat,technique"', ]); expect(message.schema).toEqual({}); }); @@ -954,8 +956,8 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "5" supplied to ""', - 'Invalid value "4" supplied to ""', + 'Invalid value "5" supplied to "false_positives"', + 'Invalid value "4" supplied to "false_positives"', ]); expect(message.schema).toEqual({}); }); @@ -1254,7 +1256,7 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "invalid-string" supplied to ""', + 'Invalid value "invalid-string" supplied to "overwrite"', ]); expect(message.schema).toEqual({}); }); @@ -1377,7 +1379,7 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "group"', + 'Invalid value "undefined" supplied to "actions,group"', ]); expect(message.schema).toEqual({}); }); @@ -1391,7 +1393,9 @@ describe('import rules schema', () => { const decoded = importRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "undefined" supplied to "id"']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "actions,id"', + ]); expect(message.schema).toEqual({}); }); @@ -1405,7 +1409,7 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "action_type_id"', + 'Invalid value "undefined" supplied to "actions,action_type_id"', ]); expect(message.schema).toEqual({}); }); @@ -1420,7 +1424,7 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "params"', + 'Invalid value "undefined" supplied to "actions,params"', ]); expect(message.schema).toEqual({}); }); @@ -1442,7 +1446,7 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "action_type_id"', + 'Invalid value "undefined" supplied to "actions,action_type_id"', ]); expect(message.schema).toEqual({}); }); @@ -1513,8 +1517,7 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - // TODO: Fix/Change the formatErrors to be better able to handle objects - 'Invalid value "[object Object]" supplied to "note"', + 'Invalid value "{"somethingHere":"something else"}" supplied to "note"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_bulk_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_bulk_schema.test.ts index 7b86c02e5c475b..a03bb2db0fd4bf 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_bulk_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_bulk_schema.test.ts @@ -92,9 +92,8 @@ describe('patch_rules_bulk_schema', () => { const decoded = patchRulesBulkSchema.decode(payload); const checked = exactCheck(payload, decoded); const output = foldLeftRight(checked); - // TODO: Fix the formatter to give something better than [object Object] expect(formatErrors(output.errors)).toEqual([ - 'Invalid value "[object Object]" supplied to "note"', + 'Invalid value "{"someprop":"some value here"}" supplied to "note"', ]); expect(output.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts index 921e07a29609c7..55363ffb183075 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts @@ -1065,9 +1065,8 @@ describe('patch_rules_schema', () => { const decoded = patchRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - // TODO: Change the formatter to output something more readable than [object Object] expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "[object Object]" supplied to "note"', + 'Invalid value "{"someProperty":"something else here"}" supplied to "note"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_bulk_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_bulk_schema.test.ts index edc652ce3b3f41..4cb38889045fce 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_bulk_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_bulk_schema.test.ts @@ -246,9 +246,8 @@ describe('update_rules_bulk_schema', () => { const decoded = updateRulesBulkSchema.decode(payload); const checked = exactCheck(payload, decoded); const output = foldLeftRight(checked); - // TODO: We should change the formatter used to better print objects expect(formatErrors(output.errors)).toEqual([ - 'Invalid value "[object Object]" supplied to "note"', + 'Invalid value "{"something":"some object"}" supplied to "note"', ]); expect(output.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts index e60522e1964f4e..1ff38f1351f591 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts @@ -608,7 +608,7 @@ describe('update rules schema', () => { const decoded = updateRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to "references"']); expect(message.schema).toEqual({}); }); @@ -756,7 +756,9 @@ describe('update rules schema', () => { const decoded = updateRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "max_signals"', + ]); expect(message.schema).toEqual({}); }); @@ -769,7 +771,7 @@ describe('update rules schema', () => { const decoded = updateRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to "max_signals"']); expect(message.schema).toEqual({}); }); @@ -817,9 +819,9 @@ describe('update rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "0" supplied to ""', - 'Invalid value "1" supplied to ""', - 'Invalid value "2" supplied to ""', + 'Invalid value "0" supplied to "tags"', + 'Invalid value "1" supplied to "tags"', + 'Invalid value "2" supplied to "tags"', ]); expect(message.schema).toEqual({}); }); @@ -851,7 +853,7 @@ describe('update rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "framework"', + 'Invalid value "undefined" supplied to "threat,framework"', ]); expect(message.schema).toEqual({}); }); @@ -879,7 +881,7 @@ describe('update rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "tactic"', + 'Invalid value "undefined" supplied to "threat,tactic"', ]); expect(message.schema).toEqual({}); }); @@ -905,7 +907,7 @@ describe('update rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "technique"', + 'Invalid value "undefined" supplied to "threat,technique"', ]); expect(message.schema).toEqual({}); }); @@ -937,8 +939,8 @@ describe('update rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "5" supplied to ""', - 'Invalid value "4" supplied to ""', + 'Invalid value "5" supplied to "false_positives"', + 'Invalid value "4" supplied to "false_positives"', ]); expect(message.schema).toEqual({}); }); @@ -1173,7 +1175,7 @@ describe('update rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "group"', + 'Invalid value "undefined" supplied to "actions,group"', ]); expect(message.schema).toEqual({}); }); @@ -1187,7 +1189,9 @@ describe('update rules schema', () => { const decoded = updateRulesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "undefined" supplied to "id"']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "actions,id"', + ]); expect(message.schema).toEqual({}); }); @@ -1201,7 +1205,7 @@ describe('update rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "action_type_id"', + 'Invalid value "undefined" supplied to "actions,action_type_id"', ]); expect(message.schema).toEqual({}); }); @@ -1216,7 +1220,7 @@ describe('update rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "params"', + 'Invalid value "undefined" supplied to "actions,params"', ]); expect(message.schema).toEqual({}); }); @@ -1238,7 +1242,7 @@ describe('update rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "action_type_id"', + 'Invalid value "undefined" supplied to "actions,action_type_id"', ]); expect(message.schema).toEqual({}); }); @@ -1323,8 +1327,7 @@ describe('update rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - // TODO: Fix/Change the formatErrors to be better able to handle objects - 'Invalid value "[object Object]" supplied to "note"', + 'Invalid value "{"somethingHere":"something else"}" supplied to "note"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/deafult_boolean_true.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/deafult_boolean_true.test.ts index a2deaf626624f8..1f111515d391ab 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/deafult_boolean_true.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/deafult_boolean_true.test.ts @@ -33,7 +33,9 @@ describe('default_boolean_true', () => { const decoded = DefaultBooleanTrue.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultBooleanTrue"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/deafult_from_string.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/deafult_from_string.test.ts index d68d447ca4454c..3862fcb95b91db 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/deafult_from_string.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/deafult_from_string.test.ts @@ -24,7 +24,9 @@ describe('default_from_string', () => { const decoded = DefaultFromString.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultFromString"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_actions_array.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_actions_array.test.ts index 645eade71916fc..ac22d36a062cb5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_actions_array.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_actions_array.test.ts @@ -39,7 +39,9 @@ describe('default_actions_array', () => { const decoded = DefaultActionsArray.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultActionsArray"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_actions_array.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_actions_array.ts index ce3eb7fa7da831..c69ae591f5ddc8 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_actions_array.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_actions_array.ts @@ -15,7 +15,8 @@ import { actions, Actions } from '../common/schemas'; export const DefaultActionsArray = new t.Type( 'DefaultActionsArray', actions.is, - (input): Either => (input == null ? t.success([]) : actions.decode(input)), + (input, context): Either => + input == null ? t.success([]) : actions.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_false.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_false.test.ts index 1697928b17e0c1..954a0f36510486 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_false.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_false.test.ts @@ -33,7 +33,9 @@ describe('default_boolean_false', () => { const decoded = DefaultBooleanFalse.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultBooleanFalse"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_false.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_false.ts index 624b9802f680c9..0cab6525779a65 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_false.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_false.ts @@ -14,8 +14,8 @@ import { Either } from 'fp-ts/lib/Either'; export const DefaultBooleanFalse = new t.Type( 'DefaultBooleanFalse', t.boolean.is, - (input): Either => - input == null ? t.success(false) : t.boolean.decode(input), + (input, context): Either => + input == null ? t.success(false) : t.boolean.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_true.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_true.ts index 58c912a0a86500..6997652b72636b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_true.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_boolean_true.ts @@ -14,7 +14,8 @@ import { Either } from 'fp-ts/lib/Either'; export const DefaultBooleanTrue = new t.Type( 'DefaultBooleanTrue', t.boolean.is, - (input): Either => (input == null ? t.success(true) : t.boolean.decode(input)), + (input, context): Either => + input == null ? t.success(true) : t.boolean.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_empty_string.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_empty_string.test.ts index 386d4c55905cd9..4c59ae44fb8d8e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_empty_string.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_empty_string.test.ts @@ -24,7 +24,9 @@ describe('default_empty_string', () => { const decoded = DefaultEmptyString.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultEmptyString"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_empty_string.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_empty_string.ts index 6216d0c1111b08..a1103c4aa8d0e9 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_empty_string.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_empty_string.ts @@ -14,7 +14,8 @@ import { Either } from 'fp-ts/lib/Either'; export const DefaultEmptyString = new t.Type( 'DefaultEmptyString', t.string.is, - (input): Either => (input == null ? t.success('') : t.string.decode(input)), + (input, context): Either => + input == null ? t.success('') : t.string.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_export_file_name.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_export_file_name.test.ts index 328cd738d7de0e..70aa9501a3080b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_export_file_name.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_export_file_name.test.ts @@ -24,7 +24,9 @@ describe('default_export_file_name', () => { const decoded = DefaultExportFileName.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultExportFileName"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_export_file_name.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_export_file_name.ts index 41dfdee1e0da05..4c7f663e7f46d6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_export_file_name.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_export_file_name.ts @@ -14,8 +14,8 @@ import { Either } from 'fp-ts/lib/Either'; export const DefaultExportFileName = new t.Type( 'DefaultExportFileName', t.string.is, - (input): Either => - input == null ? t.success('export.ndjson') : t.string.decode(input), + (input, context): Either => + input == null ? t.success('export.ndjson') : t.string.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts index 4217532de954e1..b6b432858eb920 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_from_string.ts @@ -14,8 +14,8 @@ import { Either } from 'fp-ts/lib/Either'; export const DefaultFromString = new t.Type( 'DefaultFromString', t.string.is, - (input): Either => - input == null ? t.success('now-6m') : t.string.decode(input), + (input, context): Either => + input == null ? t.success('now-6m') : t.string.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_interval_string.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_interval_string.test.ts index 9720178a4ae9b0..c7cda54a54b04a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_interval_string.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_interval_string.test.ts @@ -24,7 +24,9 @@ describe('default_interval_string', () => { const decoded = DefaultIntervalString.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultIntervalString"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_interval_string.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_interval_string.ts index 579e7591fdb03d..9492374ffe91ef 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_interval_string.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_interval_string.ts @@ -14,7 +14,8 @@ import { Either } from 'fp-ts/lib/Either'; export const DefaultIntervalString = new t.Type( 'DefaultIntervalString', t.string.is, - (input): Either => (input == null ? t.success('5m') : t.string.decode(input)), + (input, context): Either => + input == null ? t.success('5m') : t.string.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_language_string.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_language_string.test.ts index e3da8dbd280abc..e210bcf7d881f0 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_language_string.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_language_string.test.ts @@ -25,7 +25,9 @@ describe('default_language_string', () => { const decoded = DefaultLanguageString.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultLanguageString"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_language_string.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_language_string.ts index 248e15d56dfd79..1e05a46d7273c3 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_language_string.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_language_string.ts @@ -15,8 +15,8 @@ import { language } from '../common/schemas'; export const DefaultLanguageString = new t.Type( 'DefaultLanguageString', t.string.is, - (input): Either => - input == null ? t.success('kuery') : language.decode(input), + (input, context): Either => + input == null ? t.success('kuery') : language.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_max_signals_number.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_max_signals_number.test.ts index a6f137c3f21137..33ac02ee1bf53a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_max_signals_number.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_max_signals_number.test.ts @@ -25,7 +25,9 @@ describe('default_from_string', () => { const decoded = DefaultMaxSignalsNumber.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultMaxSignals"', + ]); expect(message.schema).toEqual({}); }); @@ -34,7 +36,9 @@ describe('default_from_string', () => { const decoded = DefaultMaxSignalsNumber.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "0" supplied to "DefaultMaxSignals"', + ]); expect(message.schema).toEqual({}); }); @@ -43,7 +47,9 @@ describe('default_from_string', () => { const decoded = DefaultMaxSignalsNumber.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "DefaultMaxSignals"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_max_signals_number.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_max_signals_number.ts index 6f0c32c5466f39..d3c48b5522f57a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_max_signals_number.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_max_signals_number.ts @@ -23,8 +23,8 @@ export const DefaultMaxSignalsNumber: DefaultMaxSignalsNumberC = new t.Type< >( 'DefaultMaxSignals', t.number.is, - (input): Either => { - return input == null ? t.success(DEFAULT_MAX_SIGNALS) : max_signals.decode(input); + (input, context): Either => { + return input == null ? t.success(DEFAULT_MAX_SIGNALS) : max_signals.validate(input, context); }, t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_page.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_page.test.ts index 1d1d43667c7104..dd908136463190 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_page.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_page.test.ts @@ -33,7 +33,9 @@ describe('default_page', () => { const decoded = DefaultPage.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "NaN" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "NaN" supplied to "DefaultPerPage"', + ]); expect(message.schema).toEqual({}); }); @@ -42,7 +44,9 @@ describe('default_page', () => { const decoded = DefaultPage.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "NaN" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "NaN" supplied to "DefaultPerPage"', + ]); expect(message.schema).toEqual({}); }); @@ -51,7 +55,9 @@ describe('default_page', () => { const decoded = DefaultPage.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "0" supplied to "DefaultPerPage"', + ]); expect(message.schema).toEqual({}); }); @@ -60,7 +66,9 @@ describe('default_page', () => { const decoded = DefaultPage.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "DefaultPerPage"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_page.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_page.ts index 95e3b42f3e138a..96e01d381e34b7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_page.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_page.ts @@ -17,13 +17,13 @@ import { PositiveIntegerGreaterThanZero } from './positive_integer_greater_than_ export const DefaultPage = new t.Type( 'DefaultPerPage', t.number.is, - (input): Either => { + (input, context): Either => { if (input == null) { return t.success(1); } else if (typeof input === 'string') { - return PositiveIntegerGreaterThanZero.decode(parseInt(input, 10)); + return PositiveIntegerGreaterThanZero.validate(parseInt(input, 10), context); } else { - return PositiveIntegerGreaterThanZero.decode(input); + return PositiveIntegerGreaterThanZero.validate(input, context); } }, t.identity diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_per_page.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_per_page.test.ts index 3ecbae6ed43f56..2115d6d3b52ef3 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_per_page.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_per_page.test.ts @@ -33,7 +33,9 @@ describe('default_per_page', () => { const decoded = DefaultPerPage.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "NaN" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "NaN" supplied to "DefaultPerPage"', + ]); expect(message.schema).toEqual({}); }); @@ -42,7 +44,9 @@ describe('default_per_page', () => { const decoded = DefaultPerPage.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "NaN" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "NaN" supplied to "DefaultPerPage"', + ]); expect(message.schema).toEqual({}); }); @@ -51,7 +55,9 @@ describe('default_per_page', () => { const decoded = DefaultPerPage.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "0" supplied to "DefaultPerPage"', + ]); expect(message.schema).toEqual({}); }); @@ -60,7 +66,9 @@ describe('default_per_page', () => { const decoded = DefaultPerPage.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "DefaultPerPage"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_per_page.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_per_page.ts index f96f280f6af11b..b78de8b35cede1 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_per_page.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_per_page.ts @@ -17,13 +17,13 @@ import { PositiveIntegerGreaterThanZero } from './positive_integer_greater_than_ export const DefaultPerPage = new t.Type( 'DefaultPerPage', t.number.is, - (input): Either => { + (input, context): Either => { if (input == null) { return t.success(20); } else if (typeof input === 'string') { - return PositiveIntegerGreaterThanZero.decode(parseInt(input, 10)); + return PositiveIntegerGreaterThanZero.validate(parseInt(input, 10), context); } else { - return PositiveIntegerGreaterThanZero.decode(input); + return PositiveIntegerGreaterThanZero.validate(input, context); } }, t.identity diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_array.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_array.test.ts index 83142c8d657772..6d352f3ffc4ba5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_array.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_array.test.ts @@ -33,7 +33,9 @@ describe('default_string_array', () => { const decoded = DefaultStringArray.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultStringArray"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_array.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_array.ts index 1f043cfd1b8e50..a8c53c230acd91 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_array.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_array.ts @@ -14,8 +14,8 @@ import { Either } from 'fp-ts/lib/Either'; export const DefaultStringArray = new t.Type( 'DefaultStringArray', t.array(t.string).is, - (input): Either => - input == null ? t.success([]) : t.array(t.string).decode(input), + (input, context): Either => + input == null ? t.success([]) : t.array(t.string).validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_boolean_false.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_boolean_false.test.ts index 1941a642e8bafe..dcb264d77b14b1 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_boolean_false.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_boolean_false.test.ts @@ -33,7 +33,9 @@ describe('default_string_boolean_false', () => { const decoded = DefaultStringBooleanFalse.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultStringBooleanFalse"', + ]); expect(message.schema).toEqual({}); }); @@ -78,7 +80,9 @@ describe('default_string_boolean_false', () => { const decoded = DefaultStringBooleanFalse.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "junk" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "junk" supplied to "DefaultStringBooleanFalse"', + ]); expect(message.schema).toEqual({}); }); @@ -87,7 +91,9 @@ describe('default_string_boolean_false', () => { const decoded = DefaultStringBooleanFalse.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "" supplied to "DefaultStringBooleanFalse"', + ]); expect(message.schema).toEqual({}); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_boolean_false.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_boolean_false.ts index 48a40d4b9ceec8..aa070c171d7ea0 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_boolean_false.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_string_boolean_false.ts @@ -15,7 +15,7 @@ import { Either } from 'fp-ts/lib/Either'; export const DefaultStringBooleanFalse = new t.Type( 'DefaultStringBooleanFalse', t.boolean.is, - (input): Either => { + (input, context): Either => { if (input == null) { return t.success(false); } else if (typeof input === 'string' && input.toLowerCase() === 'true') { @@ -23,7 +23,7 @@ export const DefaultStringBooleanFalse = new t.Type( } else if (typeof input === 'string' && input.toLowerCase() === 'false') { return t.success(false); } else { - return t.boolean.decode(input); + return t.boolean.validate(input, context); } }, t.identity diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_threat_array.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_threat_array.test.ts index 9819da0b8d4638..42193128cccfad 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_threat_array.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_threat_array.test.ts @@ -47,7 +47,9 @@ describe('default_threat_null', () => { const decoded = DefaultThreatArray.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultThreatArray"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_threat_array.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_threat_array.ts index da0611e24bc7e0..5499a3c1e30644 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_threat_array.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_threat_array.ts @@ -15,7 +15,8 @@ import { Threat, threat } from '../common/schemas'; export const DefaultThreatArray = new t.Type( 'DefaultThreatArray', threat.is, - (input): Either => (input == null ? t.success([]) : threat.decode(input)), + (input, context): Either => + input == null ? t.success([]) : threat.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_throttle_null.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_throttle_null.test.ts index 304fd65647c3c6..5b08de40e0aa23 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_throttle_null.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_throttle_null.test.ts @@ -25,7 +25,9 @@ describe('default_throttle_null', () => { const decoded = DefaultThrottleNull.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultThreatNull"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_throttle_null.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_throttle_null.ts index fd31594323f4df..b76a35c0265a03 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_throttle_null.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_throttle_null.ts @@ -15,8 +15,8 @@ import { ThrottleOrNull, throttle } from '../common/schemas'; export const DefaultThrottleNull = new t.Type( 'DefaultThreatNull', throttle.is, - (input): Either => - input == null ? t.success(null) : throttle.decode(input), + (input, context): Either => + input == null ? t.success(null) : throttle.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_to_string.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_to_string.test.ts index 3e22d57cedf992..96c298c805ccb3 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_to_string.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_to_string.test.ts @@ -24,7 +24,9 @@ describe('default_to_string', () => { const decoded = DefaultToString.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultToString"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_to_string.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_to_string.ts index 163bcf8c4e5b25..158eedc121c53d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_to_string.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_to_string.ts @@ -12,9 +12,10 @@ import { Either } from 'fp-ts/lib/Either'; * - If null or undefined, then a default of the string "now" will be used */ export const DefaultToString = new t.Type( - 'DefaultFromString', + 'DefaultToString', t.string.is, - (input): Either => (input == null ? t.success('now') : t.string.decode(input)), + (input, context): Either => + input == null ? t.success('now') : t.string.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_uuid.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_uuid.test.ts index 7dab8869d5d87b..4bfeed479d5821 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_uuid.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_uuid.test.ts @@ -24,7 +24,7 @@ describe('default_uuid', () => { const decoded = DefaultUuid.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to "DefaultUuid"']); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_uuid.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_uuid.ts index b0c328a93ff03d..74e32e083cc445 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_uuid.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_uuid.ts @@ -18,8 +18,8 @@ import { NonEmptyString } from './non_empty_string'; export const DefaultUuid = new t.Type( 'DefaultUuid', t.string.is, - (input): Either => - input == null ? t.success(uuid.v4()) : NonEmptyString.decode(input), + (input, context): Either => + input == null ? t.success(uuid.v4()) : NonEmptyString.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.test.ts index 65697d8830b663..6d701b94a9c4d2 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.test.ts @@ -24,7 +24,9 @@ describe('default_version_number', () => { const decoded = DefaultVersionNumber.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "0" supplied to "DefaultVersionNumber"', + ]); expect(message.schema).toEqual({}); }); @@ -33,7 +35,9 @@ describe('default_version_number', () => { const decoded = DefaultVersionNumber.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "DefaultVersionNumber"', + ]); expect(message.schema).toEqual({}); }); @@ -42,7 +46,9 @@ describe('default_version_number', () => { const decoded = DefaultVersionNumber.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultVersionNumber"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.ts index 4a310329660dfe..832c942291c32e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.ts @@ -15,7 +15,8 @@ import { version, Version } from '../common/schemas'; export const DefaultVersionNumber = new t.Type( 'DefaultVersionNumber', version.is, - (input): Either => (input == null ? t.success(1) : version.decode(input)), + (input, context): Either => + input == null ? t.success(1) : version.validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/iso_date_string.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/iso_date_string.test.ts index e8bce3f38f4b3d..ca9244419b2865 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/iso_date_string.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/iso_date_string.test.ts @@ -25,7 +25,7 @@ describe('ios_date_string', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "1582677283067" supplied to ""', + 'Invalid value "1582677283067" supplied to "IsoDateString"', ]); expect(message.schema).toEqual({}); }); @@ -35,7 +35,9 @@ describe('ios_date_string', () => { const decoded = IsoDateString.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "2000" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "2000" supplied to "IsoDateString"', + ]); expect(message.schema).toEqual({}); }); @@ -45,7 +47,7 @@ describe('ios_date_string', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "Wed, 26 Feb 2020 00:36:20 GMT" supplied to ""', + 'Invalid value "Wed, 26 Feb 2020 00:36:20 GMT" supplied to "IsoDateString"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.test.ts index 31e0a8e5c2c73b..9eb55c22756faf 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.test.ts @@ -173,8 +173,8 @@ describe('lists_default_array', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "5" supplied to ""', - 'Invalid value "5" supplied to ""', + 'Invalid value "5" supplied to "listsWithDefaultArray"', + 'Invalid value "5" supplied to "listsWithDefaultArray"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.ts index 8cdd865469112e..7fe98cdc300eff 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists_default_array.ts @@ -24,8 +24,8 @@ export type ListOperator = t.TypeOf; export const ListsDefaultArray = new t.Type( 'listsWithDefaultArray', t.array(listAnd).is, - (input): Either => - input == null ? t.success([]) : t.array(listAnd).decode(input), + (input, context): Either => + input == null ? t.success([]) : t.array(listAnd).validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/non_empty_string.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/non_empty_string.test.ts index 0a88b87421e709..0c12aa78cd2478 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/non_empty_string.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/non_empty_string.test.ts @@ -24,7 +24,9 @@ describe('non_empty_string', () => { const decoded = NonEmptyString.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "NonEmptyString"', + ]); expect(message.schema).toEqual({}); }); @@ -33,7 +35,9 @@ describe('non_empty_string', () => { const decoded = NonEmptyString.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "" supplied to "NonEmptyString"', + ]); expect(message.schema).toEqual({}); }); @@ -42,7 +46,9 @@ describe('non_empty_string', () => { const decoded = NonEmptyString.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value " " supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value " " supplied to "NonEmptyString"', + ]); expect(message.schema).toEqual({}); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/only_false_allowed.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/only_false_allowed.test.ts index 01183e59378bd7..a11fec1f064b14 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/only_false_allowed.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/only_false_allowed.test.ts @@ -24,7 +24,9 @@ describe('only_false_allowed', () => { const decoded = OnlyFalseAllowed.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "true" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "true" supplied to "DefaultBooleanTrue"', + ]); expect(message.schema).toEqual({}); }); @@ -33,7 +35,9 @@ describe('only_false_allowed', () => { const decoded = OnlyFalseAllowed.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultBooleanTrue"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/positive_integer_greater_than_zero.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/positive_integer_greater_than_zero.test.ts index 821eb066a65315..b67825b2712161 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/positive_integer_greater_than_zero.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/positive_integer_greater_than_zero.test.ts @@ -24,7 +24,9 @@ describe('positive_integer_greater_than_zero', () => { const decoded = PositiveIntegerGreaterThanZero.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "0" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "0" supplied to "PositiveIntegerGreaterThanZero"', + ]); expect(message.schema).toEqual({}); }); @@ -33,7 +35,9 @@ describe('positive_integer_greater_than_zero', () => { const decoded = PositiveIntegerGreaterThanZero.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "PositiveIntegerGreaterThanZero"', + ]); expect(message.schema).toEqual({}); }); @@ -42,7 +46,9 @@ describe('positive_integer_greater_than_zero', () => { const decoded = PositiveIntegerGreaterThanZero.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "some string" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "PositiveIntegerGreaterThanZero"', + ]); expect(message.schema).toEqual({}); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/postive_integer.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/postive_integer.test.ts index ea00ecf5efe0d1..7324f5ffda0626 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/postive_integer.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/postive_integer.test.ts @@ -33,7 +33,9 @@ describe('positive_integer_greater_than_zero', () => { const decoded = PositiveInteger.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "PositiveInteger"', + ]); expect(message.schema).toEqual({}); }); @@ -42,7 +44,9 @@ describe('positive_integer_greater_than_zero', () => { const decoded = PositiveInteger.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "some string" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "PositiveInteger"', + ]); expect(message.schema).toEqual({}); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/references_default_array.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/references_default_array.test.ts index 83142c8d657772..6d352f3ffc4ba5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/references_default_array.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/references_default_array.test.ts @@ -33,7 +33,9 @@ describe('default_string_array', () => { const decoded = DefaultStringArray.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultStringArray"', + ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/references_default_array.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/references_default_array.ts index b809181ce8c329..f246a26bdf4cb4 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/references_default_array.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/references_default_array.ts @@ -14,8 +14,8 @@ import { Either } from 'fp-ts/lib/Either'; export const ReferencesDefaultArray = new t.Type( 'referencesWithDefaultArray', t.array(t.string).is, - (input): Either => - input == null ? t.success([]) : t.array(t.string).decode(input), + (input, context): Either => + input == null ? t.success([]) : t.array(t.string).validate(input, context), t.identity ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/risk_score.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/risk_score.test.ts index cf849f28a09636..c8bf3cbecdaaf4 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/risk_score.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/risk_score.test.ts @@ -33,7 +33,7 @@ describe('risk_score', () => { const decoded = RiskScore.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "-1" supplied to "RiskScore"']); expect(message.schema).toEqual({}); }); @@ -42,7 +42,9 @@ describe('risk_score', () => { const decoded = RiskScore.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "some string" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "RiskScore"', + ]); expect(message.schema).toEqual({}); }); @@ -51,7 +53,7 @@ describe('risk_score', () => { const decoded = RiskScore.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "101" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "101" supplied to "RiskScore"']); expect(message.schema).toEqual({}); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/uuid.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/uuid.test.ts index d3a68a7575487f..2bfaa3603d6a78 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/uuid.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/uuid.test.ts @@ -25,7 +25,7 @@ describe('uuid', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "4656dc92-5832-11ea-8e2d" supplied to ""', + 'Invalid value "4656dc92-5832-11ea-8e2d" supplied to "UUID"', ]); expect(message.schema).toEqual({}); }); @@ -35,7 +35,7 @@ describe('uuid', () => { const decoded = UUID.decode(payload); const message = pipe(decoded, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to ""']); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "UUID"']); expect(message.schema).toEqual({}); }); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts index ba4f2251564e89..d7b653916970fa 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts @@ -47,7 +47,7 @@ describe('data generator', () => { const metadata = generator.generateHostMetadata(timestamp); expect(metadata['@timestamp']).toEqual(timestamp); expect(metadata.event.created).toEqual(timestamp); - expect(metadata.endpoint).not.toBeNull(); + expect(metadata.Endpoint).not.toBeNull(); expect(metadata.agent).not.toBeNull(); expect(metadata.host).not.toBeNull(); }); @@ -57,10 +57,10 @@ describe('data generator', () => { const hostPolicyResponse = generator.generatePolicyResponse(timestamp); expect(hostPolicyResponse['@timestamp']).toEqual(timestamp); expect(hostPolicyResponse.event.created).toEqual(timestamp); - expect(hostPolicyResponse.endpoint).not.toBeNull(); + expect(hostPolicyResponse.Endpoint).not.toBeNull(); expect(hostPolicyResponse.agent).not.toBeNull(); expect(hostPolicyResponse.host).not.toBeNull(); - expect(hostPolicyResponse.endpoint.policy.applied).not.toBeNull(); + expect(hostPolicyResponse.Endpoint.policy.applied).not.toBeNull(); }); it('creates alert event documents', () => { @@ -68,7 +68,7 @@ describe('data generator', () => { const alert = generator.generateAlert(timestamp); expect(alert['@timestamp']).toEqual(timestamp); expect(alert.event.action).not.toBeNull(); - expect(alert.endpoint).not.toBeNull(); + expect(alert.Endpoint).not.toBeNull(); expect(alert.agent).not.toBeNull(); expect(alert.host).not.toBeNull(); expect(alert.process.entity_id).not.toBeNull(); @@ -364,7 +364,9 @@ describe('data generator', () => { it('creates full resolver tree', () => { const alertAncestors = 3; const generations = 2; - const events = [...generator.fullResolverTreeGenerator(alertAncestors, generations)]; + const events = [ + ...generator.fullResolverTreeGenerator({ ancestors: alertAncestors, generations }), + ]; const rootNode = buildResolverTree(events); const visitedEvents = countResolverEvents(rootNode, alertAncestors + generations); expect(visitedEvents).toEqual(events.length); diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 7944d7d365ed89..8c032859967791 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -10,7 +10,7 @@ import { EndpointEvent, Host, HostMetadata, - HostOS, + OSFields, HostPolicyResponse, HostPolicyResponseActionStatus, PolicyData, @@ -28,38 +28,46 @@ interface EventOptions { processName?: string; } -const Windows: HostOS[] = [ +const Windows: OSFields[] = [ { name: 'windows 10.0', full: 'Windows 10', version: '10.0', - variant: 'Windows Pro', + Ext: { + variant: 'Windows Pro', + }, }, { name: 'windows 10.0', full: 'Windows Server 2016', version: '10.0', - variant: 'Windows Server', + Ext: { + variant: 'Windows Server', + }, }, { name: 'windows 6.2', full: 'Windows Server 2012', version: '6.2', - variant: 'Windows Server', + Ext: { + variant: 'Windows Server', + }, }, { name: 'windows 6.3', full: 'Windows Server 2012R2', version: '6.3', - variant: 'Windows Server Release 2', + Ext: { + variant: 'Windows Server Release 2', + }, }, ]; -const Linux: HostOS[] = []; +const Linux: OSFields[] = []; -const Mac: HostOS[] = []; +const Mac: OSFields[] = []; -const OS: HostOS[] = [...Windows, ...Mac, ...Linux]; +const OS: OSFields[] = [...Windows, ...Mac, ...Linux]; const APPLIED_POLICIES: Array<{ name: string; @@ -186,7 +194,7 @@ interface HostInfo { type: string; }; host: Host; - endpoint: { + Endpoint: { policy: { applied: { id: string; @@ -283,8 +291,8 @@ export class EndpointDocGenerator { * Creates new random policy id for the host to simulate new policy application */ public updatePolicyId() { - this.commonInfo.endpoint.policy.applied.id = this.randomChoice(APPLIED_POLICIES).id; - this.commonInfo.endpoint.policy.applied.status = this.randomChoice([ + this.commonInfo.Endpoint.policy.applied = this.randomChoice(APPLIED_POLICIES); + this.commonInfo.Endpoint.policy.applied.status = this.randomChoice([ HostPolicyResponseActionStatus.success, HostPolicyResponseActionStatus.failure, HostPolicyResponseActionStatus.warning, @@ -310,7 +318,7 @@ export class EndpointDocGenerator { mac: this.randomArray(3, () => this.randomMac()), os: this.randomChoice(OS), }, - endpoint: { + Endpoint: { policy: { applied: this.randomChoice(APPLIED_POLICIES), }, @@ -371,77 +379,88 @@ export class EndpointDocGenerator { sha1: 'fake file sha1', sha256: 'fake file sha256', }, - code_signature: { - trusted: false, - subject_name: 'bad signer', - }, - malware_classification: { - identifier: 'endpointpe', - score: 1, - threshold: 0.66, - version: '3.0.33', + Ext: { + code_signature: [ + { + trusted: false, + subject_name: 'bad signer', + }, + ], + malware_classification: { + identifier: 'endpointpe', + score: 1, + threshold: 0.66, + version: '3.0.33', + }, + temp_file_path: 'C:/temp/fake_malware.exe', }, - temp_file_path: 'C:/temp/fake_malware.exe', }, process: { pid: 2, name: 'malware writer', start: ts, uptime: 0, - user: 'SYSTEM', entity_id: entityID, executable: 'C:/malware.exe', parent: parentEntityID ? { entity_id: parentEntityID, pid: 1 } : undefined, - token: { - domain: 'NT AUTHORITY', - integrity_level: 16384, - integrity_level_name: 'system', - privileges: [ - { - description: 'Replace a process level token', - enabled: false, - name: 'SeAssignPrimaryTokenPrivilege', - }, - ], - sid: 'S-1-5-18', - type: 'tokenPrimary', - user: 'SYSTEM', - }, - code_signature: { - trusted: false, - subject_name: 'bad signer', - }, hash: { md5: 'fake md5', sha1: 'fake sha1', sha256: 'fake sha256', }, + Ext: { + code_signature: [ + { + trusted: false, + subject_name: 'bad signer', + }, + ], + user: 'SYSTEM', + token: { + domain: 'NT AUTHORITY', + integrity_level: 16384, + integrity_level_name: 'system', + privileges: [ + { + description: 'Replace a process level token', + enabled: false, + name: 'SeAssignPrimaryTokenPrivilege', + }, + ], + sid: 'S-1-5-18', + type: 'tokenPrimary', + user: 'SYSTEM', + }, + }, }, dll: [ { pe: { architecture: 'x64', - imphash: 'c30d230b81c734e82e86e2e2fe01cd01', }, code_signature: { subject_name: 'Cybereason Inc', trusted: true, }, - compile_time: 1534424710, + hash: { md5: '1f2d082566b0fc5f2c238a5180db7451', sha1: 'ca85243c0af6a6471bdaa560685c51eefd6dbc0d', sha256: '8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2', }, - malware_classification: { - identifier: 'Whitelisted', - score: 0, - threshold: 0, - version: '3.0.0', - }, - mapped_address: 5362483200, - mapped_size: 0, + path: 'C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe', + Ext: { + compile_time: 1534424710, + mapped_address: 5362483200, + mapped_size: 0, + malware_classification: { + identifier: 'Whitelisted', + score: 0, + threshold: 0, + version: '3.0.0', + }, + }, }, ], }; @@ -561,28 +580,9 @@ export class EndpointDocGenerator { * @param percentTerminated - percent of nodes which will have process termination events * @param alwaysGenMaxChildrenPerNode - flag to always return the max children per node instead of it being a random number of children */ - public *alertsGenerator( - numAlerts: number, - alertAncestors?: number, - childGenerations?: number, - maxChildrenPerNode?: number, - relatedEventsPerNode?: number, - relatedAlertsPerNode?: number, - percentNodesWithRelated?: number, - percentTerminated?: number, - alwaysGenMaxChildrenPerNode?: boolean - ) { + public *alertsGenerator(numAlerts: number, options: TreeOptions = {}) { for (let i = 0; i < numAlerts; i++) { - yield* this.fullResolverTreeGenerator( - alertAncestors, - childGenerations, - maxChildrenPerNode, - relatedEventsPerNode, - relatedAlertsPerNode, - percentNodesWithRelated, - percentTerminated, - alwaysGenMaxChildrenPerNode - ); + yield* this.fullResolverTreeGenerator(options); } } @@ -600,21 +600,12 @@ export class EndpointDocGenerator { * @param percentTerminated - percent of nodes which will have process termination events * @param alwaysGenMaxChildrenPerNode - flag to always return the max children per node instead of it being a random number of children */ - public *fullResolverTreeGenerator( - alertAncestors?: number, - childGenerations?: number, - maxChildrenPerNode?: number, - relatedEventsPerNode?: RelatedEventInfo[] | number, - relatedAlertsPerNode?: number, - percentNodesWithRelated?: number, - percentTerminated?: number, - alwaysGenMaxChildrenPerNode?: boolean - ) { + public *fullResolverTreeGenerator(options: TreeOptions = {}) { const ancestry = this.createAlertEventAncestry( - alertAncestors, - relatedEventsPerNode, - percentNodesWithRelated, - percentTerminated + options.ancestors, + options.relatedEvents, + options.percentWithRelated, + options.percentTerminated ); for (let i = 0; i < ancestry.length; i++) { yield ancestry[i]; @@ -622,13 +613,13 @@ export class EndpointDocGenerator { // ancestry will always have at least 2 elements, and the last element will be the alert yield* this.descendantsTreeGenerator( ancestry[ancestry.length - 1], - childGenerations, - maxChildrenPerNode, - relatedEventsPerNode, - relatedAlertsPerNode, - percentNodesWithRelated, - percentTerminated, - alwaysGenMaxChildrenPerNode + options.generations, + options.children, + options.relatedEvents, + options.relatedAlerts, + options.percentWithRelated, + options.percentTerminated, + options.alwaysGenMaxChildrenPerNode ); } @@ -940,7 +931,7 @@ export class EndpointDocGenerator { host: { id: this.commonInfo.host.id, }, - endpoint: { + Endpoint: { policy: { applied: { actions: [ @@ -1045,7 +1036,7 @@ export class EndpointDocGenerator { status: HostPolicyResponseActionStatus.success, }, ], - id: this.commonInfo.endpoint.policy.applied.id, + id: this.commonInfo.Endpoint.policy.applied.id, response: { configurations: { events: { @@ -1086,9 +1077,9 @@ export class EndpointDocGenerator { ], }, }, - status: this.commonInfo.endpoint.policy.applied.status, + status: this.commonInfo.Endpoint.policy.applied.status, version: policyVersion, - name: this.commonInfo.endpoint.policy.applied.name, + name: this.commonInfo.Endpoint.policy.applied.name, }, }, }, diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts new file mode 100644 index 00000000000000..d868ba63b1edcd --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Client } from '@elastic/elasticsearch'; +import seedrandom from 'seedrandom'; +import { EndpointDocGenerator, TreeOptions, Event } from './generate_data'; + +export async function indexHostsAndAlerts( + client: Client, + seed: string, + numHosts: number, + numDocs: number, + metadataIndex: string, + policyIndex: string, + eventIndex: string, + alertsPerHost: number, + options: TreeOptions = {} +) { + const random = seedrandom(seed); + for (let i = 0; i < numHosts; i++) { + const generator = new EndpointDocGenerator(random); + await indexHostDocs(numDocs, client, metadataIndex, policyIndex, generator); + await indexAlerts(client, eventIndex, generator, alertsPerHost, options); + } + await client.indices.refresh({ + index: eventIndex, + }); + // TODO: Unclear why the documents are not showing up after the call to refresh. + // Waiting 5 seconds allows the indices to refresh automatically and + // the documents become available in API/integration tests. + await delay(5000); +} + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function indexHostDocs( + numDocs: number, + client: Client, + metadataIndex: string, + policyIndex: string, + generator: EndpointDocGenerator +) { + const timeBetweenDocs = 6 * 3600 * 1000; // 6 hours between metadata documents + const timestamp = new Date().getTime(); + for (let j = 0; j < numDocs; j++) { + generator.updateHostData(); + generator.updatePolicyId(); + await client.index({ + index: metadataIndex, + body: generator.generateHostMetadata(timestamp - timeBetweenDocs * (numDocs - j - 1)), + op_type: 'create', + }); + await client.index({ + index: policyIndex, + body: generator.generatePolicyResponse(timestamp - timeBetweenDocs * (numDocs - j - 1)), + op_type: 'create', + }); + } +} + +async function indexAlerts( + client: Client, + index: string, + generator: EndpointDocGenerator, + numAlerts: number, + options: TreeOptions = {} +) { + const alertGenerator = generator.alertsGenerator(numAlerts, options); + let result = alertGenerator.next(); + while (!result.done) { + let k = 0; + const resolverDocs: Event[] = []; + while (k < 1000 && !result.done) { + resolverDocs.push(result.value); + result = alertGenerator.next(); + k++; + } + const body = resolverDocs.reduce( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (array: Array>, doc) => ( + array.push({ create: { _index: index } }, doc), array + ), + [] + ); + await client.bulk({ body, refresh: 'true' }); + } +} diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index 0341b7593caf06..bd4d8372497f15 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -173,12 +173,19 @@ export interface HostResultList { } /** - * Operating System metadata for a host. + * Operating System metadata. */ -export interface HostOS { +export interface OSFields { full: string; name: string; version: string; + Ext: OSFieldsExt; +} + +/** + * Extended Operating System metadata. + */ +export interface OSFieldsExt { variant: string; } @@ -190,7 +197,7 @@ export interface Host { hostname: string; ip: string[]; mac: string[]; - os: HostOS; + os: OSFields; } /** @@ -220,27 +227,30 @@ interface MalwareClassification { interface ThreadFields { id: number; - service_name: string; - start: number; - start_address: number; - start_address_module: string; + Ext: { + service_name: string; + start: number; + start_address: number; + start_address_module: string; + }; } interface DllFields { + hash: Hashes; + path: string; pe: { architecture: string; - imphash: string; }; code_signature: { subject_name: string; trusted: boolean; }; - compile_time: number; - hash: Hashes; - malware_classification: MalwareClassification; - mapped_address: number; - mapped_size: number; - path: string; + Ext: { + compile_time: number; + malware_classification: MalwareClassification; + mapped_address: number; + mapped_size: number; + }; } /** @@ -265,7 +275,7 @@ export interface AlertEvent { module: string; type: string; }; - endpoint: { + Endpoint: { policy: { applied: { id: string; @@ -275,12 +285,7 @@ export interface AlertEvent { }; }; process: { - code_signature: { - subject_name: string; - trusted: boolean; - }; command_line?: string; - domain?: string; pid: number; ppid?: number; entity_id: string; @@ -290,29 +295,31 @@ export interface AlertEvent { }; name: string; hash: Hashes; - pe?: { - imphash: string; - }; executable: string; - sid?: string; start: number; - malware_classification?: MalwareClassification; - token: { - domain: string; - type: string; - user: string; - sid: string; - integrity_level: number; - integrity_level_name: string; - privileges?: Array<{ - description: string; - name: string; - enabled: boolean; - }>; - }; thread?: ThreadFields[]; uptime: number; - user: string; + Ext: { + code_signature: Array<{ + subject_name: string; + trusted: boolean; + }>; + malware_classification?: MalwareClassification; + token: { + domain: string; + type: string; + user: string; + sid: string; + integrity_level: number; + integrity_level_name: string; + privileges?: Array<{ + description: string; + name: string; + enabled: boolean; + }>; + }; + user: string; + }; }; file: { owner: string; @@ -323,15 +330,14 @@ export interface AlertEvent { created: number; size: number; hash: Hashes; - pe?: { - imphash: string; - }; - code_signature: { - trusted: boolean; - subject_name: string; + Ext: { + malware_classification: MalwareClassification; + temp_file_path: string; + code_signature: Array<{ + trusted: boolean; + subject_name: string; + }>; }; - malware_classification: MalwareClassification; - temp_file_path: string; }; host: Host; dll?: DllFields[]; @@ -373,7 +379,7 @@ export type HostMetadata = Immutable<{ id: string; }; }; - endpoint: { + Endpoint: { policy: { applied: { id: string; @@ -666,7 +672,7 @@ export interface HostPolicyResponseAppliedAction { message: string; } -export type HostPolicyResponseConfiguration = HostPolicyResponse['endpoint']['policy']['applied']['response']['configurations']; +export type HostPolicyResponseConfiguration = HostPolicyResponse['Endpoint']['policy']['applied']['response']['configurations']; interface HostPolicyResponseConfigurationStatus { status: HostPolicyResponseActionStatus; @@ -711,7 +717,7 @@ export interface HostPolicyResponse { version: string; id: string; }; - endpoint: { + Endpoint: { policy: { applied: { version: string; diff --git a/x-pack/plugins/security_solution/common/format_errors.test.ts b/x-pack/plugins/security_solution/common/format_errors.test.ts index f9dd9e76a1d9c6..c8cd72b72816b6 100644 --- a/x-pack/plugins/security_solution/common/format_errors.test.ts +++ b/x-pack/plugins/security_solution/common/format_errors.test.ts @@ -127,4 +127,45 @@ describe('utils', () => { 'Invalid value "Some existing error 1" supplied to "some string key 2"', ]); }); + + test('will use a name context if it cannot find a keyContext', () => { + const context: t.Context = ([ + { key: '' }, + { key: '', type: { name: 'someName' } }, + ] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context, + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual(['Invalid value "Some existing error 1" supplied to "someName"']); + }); + + test('will return an empty string if name does not exist but type does', () => { + const context: t.Context = ([{ key: '' }, { key: '', type: {} }] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context, + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual(['Invalid value "Some existing error 1" supplied to ""']); + }); + + test('will stringify an error value', () => { + const context: t.Context = ([ + { key: '' }, + { key: 'some string key 2' }, + ] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: { foo: 'some error' }, + context, + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual([ + 'Invalid value "{"foo":"some error"}" supplied to "some string key 2"', + ]); + }); }); diff --git a/x-pack/plugins/security_solution/common/format_errors.ts b/x-pack/plugins/security_solution/common/format_errors.ts index d712979f9eff3b..ba963f34f2983f 100644 --- a/x-pack/plugins/security_solution/common/format_errors.ts +++ b/x-pack/plugins/security_solution/common/format_errors.ts @@ -5,19 +5,25 @@ */ import * as t from 'io-ts'; +import { isObject } from 'lodash/fp'; export const formatErrors = (errors: t.Errors): string[] => { return errors.map((error) => { if (error.message != null) { return error.message; } else { - const mappedContext = error.context + const keyContext = error.context .filter( (entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== '' ) .map((entry) => entry.key) .join(','); - return `Invalid value "${error.value}" supplied to "${mappedContext}"`; + + const nameContext = error.context.find((entry) => entry.type?.name?.length > 0); + const suppliedValue = + keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : ''; + const value = isObject(error.value) ? JSON.stringify(error.value) : error.value; + return `Invalid value "${value}" supplied to "${suppliedValue}"`; } }); }; diff --git a/x-pack/plugins/drilldowns/scripts/storybook.js b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptions.mock.ts similarity index 54% rename from x-pack/plugins/drilldowns/scripts/storybook.js rename to x-pack/plugins/security_solution/public/common/components/exceptions/exceptions.mock.ts index 2bfd0eb1a8f19c..e0bcc73c8bc491 100644 --- a/x-pack/plugins/drilldowns/scripts/storybook.js +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptions.mock.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { join } from 'path'; +import { FormattedEntry } from './types'; -// eslint-disable-next-line -require('@kbn/storybook').runStorybookCli({ - name: 'drilldowns', - storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], +export const getFormattedEntryMock = (isNested = false): FormattedEntry => ({ + fieldName: 'host.name', + operator: 'is', + value: 'some name', + isNested, }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index d86a84996a34f6..2239de3764326f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -10,7 +10,6 @@ import moment from 'moment-timezone'; import { getOperatorType, getExceptionOperatorSelect, - isEntryNested, getFormattedEntries, formatEntry, getOperatingSystems, @@ -18,13 +17,7 @@ import { getDescriptionListContent, getFormattedComments, } from './helpers'; -import { - OperatorType, - Operator, - NestedExceptionEntry, - FormattedEntry, - DescriptionListItem, -} from './types'; +import { FormattedEntry, DescriptionListItem } from './types'; import { isOperator, isNotOperator, @@ -35,7 +28,16 @@ import { existsOperator, doesNotExistOperator, } from './operators'; -import { getExceptionItemEntryMock, getExceptionItemMock } from './mocks'; +import { OperatorTypeEnum } from '../../../lists_plugin_deps'; +import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { + getEntryExistsMock, + getEntryListMock, + getEntryMatchMock, + getEntryMatchAnyMock, + getEntriesArrayMock, +} from '../../../../../lists/common/schemas/types/entries.mock'; +import { getCommentsMock } from '../../../../../lists/common/schemas/types/comments.mock'; describe('Exception helpers', () => { beforeEach(() => { @@ -48,137 +50,96 @@ describe('Exception helpers', () => { describe('#getOperatorType', () => { test('returns operator type "match" if entry.type is "match"', () => { - const payload = getExceptionItemEntryMock(); - payload.type = 'match'; - const operatorType = getOperatorType(payload); - - expect(operatorType).toEqual(OperatorType.PHRASE); - }); - - test('returns operator type "match" if entry.type is "nested"', () => { - const payload = getExceptionItemEntryMock(); - payload.type = 'nested'; + const payload = getEntryMatchMock(); const operatorType = getOperatorType(payload); - expect(operatorType).toEqual(OperatorType.PHRASE); + expect(operatorType).toEqual(OperatorTypeEnum.MATCH); }); test('returns operator type "match_any" if entry.type is "match_any"', () => { - const payload = getExceptionItemEntryMock(); - payload.type = 'match_any'; + const payload = getEntryMatchAnyMock(); const operatorType = getOperatorType(payload); - expect(operatorType).toEqual(OperatorType.PHRASES); + expect(operatorType).toEqual(OperatorTypeEnum.MATCH_ANY); }); test('returns operator type "list" if entry.type is "list"', () => { - const payload = getExceptionItemEntryMock(); - payload.type = 'list'; + const payload = getEntryListMock(); const operatorType = getOperatorType(payload); - expect(operatorType).toEqual(OperatorType.LIST); + expect(operatorType).toEqual(OperatorTypeEnum.LIST); }); test('returns operator type "exists" if entry.type is "exists"', () => { - const payload = getExceptionItemEntryMock(); - payload.type = 'exists'; + const payload = getEntryExistsMock(); const operatorType = getOperatorType(payload); - expect(operatorType).toEqual(OperatorType.EXISTS); + expect(operatorType).toEqual(OperatorTypeEnum.EXISTS); }); }); describe('#getExceptionOperatorSelect', () => { test('it returns "isOperator" when "operator" is "included" and operator type is "match"', () => { - const payload = getExceptionItemEntryMock(); + const payload = getEntryMatchMock(); const result = getExceptionOperatorSelect(payload); expect(result).toEqual(isOperator); }); test('it returns "isNotOperator" when "operator" is "excluded" and operator type is "match"', () => { - const payload = getExceptionItemEntryMock(); - payload.operator = Operator.EXCLUSION; + const payload = getEntryMatchMock(); + payload.operator = 'excluded'; const result = getExceptionOperatorSelect(payload); expect(result).toEqual(isNotOperator); }); test('it returns "isOneOfOperator" when "operator" is "included" and operator type is "match_any"', () => { - const payload = getExceptionItemEntryMock(); - payload.type = 'match_any'; - payload.operator = Operator.INCLUSION; + const payload = getEntryMatchAnyMock(); const result = getExceptionOperatorSelect(payload); expect(result).toEqual(isOneOfOperator); }); test('it returns "isNotOneOfOperator" when "operator" is "excluded" and operator type is "match_any"', () => { - const payload = getExceptionItemEntryMock(); - payload.type = 'match_any'; - payload.operator = Operator.EXCLUSION; + const payload = getEntryMatchAnyMock(); + payload.operator = 'excluded'; const result = getExceptionOperatorSelect(payload); expect(result).toEqual(isNotOneOfOperator); }); test('it returns "existsOperator" when "operator" is "included" and no operator type is provided', () => { - const payload = getExceptionItemEntryMock(); - payload.type = 'exists'; - payload.operator = Operator.INCLUSION; + const payload = getEntryExistsMock(); const result = getExceptionOperatorSelect(payload); expect(result).toEqual(existsOperator); }); test('it returns "doesNotExistsOperator" when "operator" is "excluded" and no operator type is provided', () => { - const payload = getExceptionItemEntryMock(); - payload.type = 'exists'; - payload.operator = Operator.EXCLUSION; + const payload = getEntryExistsMock(); + payload.operator = 'excluded'; const result = getExceptionOperatorSelect(payload); expect(result).toEqual(doesNotExistOperator); }); test('it returns "isInList" when "operator" is "included" and operator type is "list"', () => { - const payload = getExceptionItemEntryMock(); - payload.type = 'list'; - payload.operator = Operator.INCLUSION; + const payload = getEntryListMock(); const result = getExceptionOperatorSelect(payload); expect(result).toEqual(isInListOperator); }); test('it returns "isNotInList" when "operator" is "excluded" and operator type is "list"', () => { - const payload = getExceptionItemEntryMock(); - payload.type = 'list'; - payload.operator = Operator.EXCLUSION; + const payload = getEntryListMock(); + payload.operator = 'excluded'; const result = getExceptionOperatorSelect(payload); expect(result).toEqual(isNotInListOperator); }); }); - describe('#isEntryNested', () => { - test('it returns true if type NestedExceptionEntry', () => { - const payload: NestedExceptionEntry = { - field: 'actingProcess.file.signer', - type: 'nested', - entries: [], - }; - const result = isEntryNested(payload); - - expect(result).toBeTruthy(); - }); - - test('it returns false if NOT type NestedExceptionEntry', () => { - const payload = getExceptionItemEntryMock(); - const result = isEntryNested(payload); - - expect(result).toBeFalsy(); - }); - }); - describe('#getFormattedEntries', () => { test('it returns empty array if no entries passed', () => { const result = getFormattedEntries([]); @@ -187,116 +148,98 @@ describe('Exception helpers', () => { }); test('it formats nested entries as expected', () => { - const payload = [ - { - field: 'file.signature', - type: 'nested', - entries: [ - { - field: 'signer', - type: 'match', - operator: Operator.INCLUSION, - value: 'Evil', - }, - { - field: 'trusted', - type: 'match', - operator: Operator.INCLUSION, - value: 'true', - }, - ], - }, - ]; + const payload = [getEntryMatchMock()]; const result = getFormattedEntries(payload); const expected: FormattedEntry[] = [ { - fieldName: 'file.signature', - operator: null, - value: null, + fieldName: 'host.name', isNested: false, - }, - { - fieldName: 'file.signature.signer', - isNested: true, operator: 'is', - value: 'Evil', - }, - { - fieldName: 'file.signature.trusted', - isNested: true, - operator: 'is', - value: 'true', + value: 'some host name', }, ]; expect(result).toEqual(expected); }); - test('it formats non-nested entries as expected', () => { - const payload = [ - { - field: 'actingProcess.file.signer', - type: 'match', - operator: Operator.INCLUSION, - value: 'Elastic, N.V.', - }, + test('it formats "exists" entries as expected', () => { + const payload = [getEntryExistsMock()]; + const result = getFormattedEntries(payload); + const expected: FormattedEntry[] = [ { - field: 'actingProcess.file.signer', - type: 'match', - operator: Operator.EXCLUSION, - value: 'Global Signer', + fieldName: 'host.name', + isNested: false, + operator: 'exists', + value: null, }, ]; + expect(result).toEqual(expected); + }); + + test('it formats non-nested entries as expected', () => { + const payload = [getEntryMatchAnyMock(), getEntryMatchMock()]; const result = getFormattedEntries(payload); const expected: FormattedEntry[] = [ { - fieldName: 'actingProcess.file.signer', + fieldName: 'host.name', isNested: false, - operator: 'is', - value: 'Elastic, N.V.', + operator: 'is one of', + value: ['some host name'], }, { - fieldName: 'actingProcess.file.signer', + fieldName: 'host.name', isNested: false, - operator: 'is not', - value: 'Global Signer', + operator: 'is', + value: 'some host name', }, ]; expect(result).toEqual(expected); }); test('it formats a mix of nested and non-nested entries as expected', () => { - const payload = getExceptionItemMock(); - const result = getFormattedEntries(payload.entries); + const payload = getEntriesArrayMock(); + const result = getFormattedEntries(payload); const expected: FormattedEntry[] = [ { - fieldName: 'actingProcess.file.signer', + fieldName: 'host.name', isNested: false, operator: 'is', - value: 'Elastic, N.V.', + value: 'some host name', + }, + { + fieldName: 'host.name', + isNested: false, + operator: 'is one of', + value: ['some host name'], + }, + { + fieldName: 'host.name', + isNested: false, + operator: 'is in list', + value: ['some host name'], }, { fieldName: 'host.name', isNested: false, - operator: 'is not', - value: 'Global Signer', + operator: 'exists', + value: null, }, { - fieldName: 'file.signature', + fieldName: 'host.name', isNested: false, operator: null, value: null, }, { - fieldName: 'file.signature.signer', + fieldName: 'host.name.host.name', isNested: true, operator: 'is', - value: 'Evil', + value: 'some host name', }, { - fieldName: 'file.signature.trusted', + fieldName: 'host.name.host.name', isNested: true, - operator: 'is', - value: 'true', + operator: 'exists', + value: null, }, ]; expect(result).toEqual(expected); @@ -305,26 +248,26 @@ describe('Exception helpers', () => { describe('#formatEntry', () => { test('it formats an entry', () => { - const payload = getExceptionItemEntryMock(); + const payload = getEntryMatchMock(); const formattedEntry = formatEntry({ isNested: false, item: payload }); const expected: FormattedEntry = { - fieldName: 'actingProcess.file.signer', + fieldName: 'host.name', isNested: false, operator: 'is', - value: 'Elastic, N.V.', + value: 'some host name', }; expect(formattedEntry).toEqual(expected); }); - test('it formats a nested entry', () => { - const payload = getExceptionItemEntryMock(); + test('it formats as expected when "isNested" is "true"', () => { + const payload = getEntryMatchMock(); const formattedEntry = formatEntry({ isNested: true, parent: 'parent', item: payload }); const expected: FormattedEntry = { - fieldName: 'parent.actingProcess.file.signer', + fieldName: 'parent.host.name', isNested: true, operator: 'is', - value: 'Elastic, N.V.', + value: 'some host name', }; expect(formattedEntry).toEqual(expected); @@ -373,12 +316,12 @@ describe('Exception helpers', () => { describe('#getDescriptionListContent', () => { test('it returns formatted description list with os if one is specified', () => { - const payload = getExceptionItemMock(); + const payload = getExceptionListItemSchemaMock(); payload.description = ''; const result = getDescriptionListContent(payload); const expected: DescriptionListItem[] = [ { - description: 'Windows', + description: 'Linux', title: 'OS', }, { @@ -395,7 +338,7 @@ describe('Exception helpers', () => { }); test('it returns formatted description list with a description if one specified', () => { - const payload = getExceptionItemMock(); + const payload = getExceptionListItemSchemaMock(); payload._tags = []; payload.description = 'Im a description'; const result = getDescriptionListContent(payload); @@ -418,7 +361,7 @@ describe('Exception helpers', () => { }); test('it returns just user and date created if no other fields specified', () => { - const payload = getExceptionItemMock(); + const payload = getExceptionListItemSchemaMock(); payload._tags = []; payload.description = ''; const result = getDescriptionListContent(payload); @@ -439,29 +382,29 @@ describe('Exception helpers', () => { describe('#getFormattedComments', () => { test('it returns formatted comment object with username and timestamp', () => { - const payload = getExceptionItemMock().comments; + const payload = getCommentsMock(); const result = getFormattedComments(payload); - expect(result[0].username).toEqual('user_name'); - expect(result[0].timestamp).toEqual('on Apr 23rd 2020 @ 00:19:13'); + expect(result[0].username).toEqual('some user'); + expect(result[0].timestamp).toEqual('on Apr 20th 2020 @ 15:25:31'); }); test('it returns formatted timeline icon with comment users initial', () => { - const payload = getExceptionItemMock().comments; + const payload = getCommentsMock(); const result = getFormattedComments(payload); const wrapper = mount(result[0].timelineIcon as React.ReactElement); - expect(wrapper.text()).toEqual('U'); + expect(wrapper.text()).toEqual('SU'); }); test('it returns comment text', () => { - const payload = getExceptionItemMock().comments; + const payload = getCommentsMock(); const result = getFormattedComments(payload); const wrapper = mount(result[0].children as React.ReactElement); - expect(wrapper.text()).toEqual('Comment goes here'); + expect(wrapper.text()).toEqual('some comment'); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 9bc39d72322ae9..f8b9c39801ae5a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -10,34 +10,32 @@ import { capitalize } from 'lodash'; import moment from 'moment'; import * as i18n from './translations'; +import { FormattedEntry, OperatorOption, DescriptionListItem, Comment } from './types'; +import { EXCEPTION_OPERATORS, isOperator } from './operators'; import { - FormattedEntry, - OperatorType, - OperatorOption, - ExceptionEntry, - NestedExceptionEntry, - DescriptionListItem, - Comment, + Entry, + EntriesArray, ExceptionListItemSchema, -} from './types'; -import { EXCEPTION_OPERATORS, isOperator } from './operators'; + OperatorTypeEnum, + entriesNested, + entriesExists, +} from '../../../lists_plugin_deps'; /** * Returns the operator type, may not need this if using io-ts types * * @param entry a single ExceptionItem entry */ -export const getOperatorType = (entry: ExceptionEntry): OperatorType => { +export const getOperatorType = (entry: Entry): OperatorTypeEnum => { switch (entry.type) { - case 'nested': case 'match': - return OperatorType.PHRASE; + return OperatorTypeEnum.MATCH; case 'match_any': - return OperatorType.PHRASES; + return OperatorTypeEnum.MATCH_ANY; case 'list': - return OperatorType.LIST; + return OperatorTypeEnum.LIST; default: - return OperatorType.EXISTS; + return OperatorTypeEnum.EXISTS; } }; @@ -47,22 +45,17 @@ export const getOperatorType = (entry: ExceptionEntry): OperatorType => { * * @param entry a single ExceptionItem entry */ -export const getExceptionOperatorSelect = (entry: ExceptionEntry): OperatorOption => { - const operatorType = getOperatorType(entry); - const foundOperator = EXCEPTION_OPERATORS.find((operatorOption) => { - return entry.operator === operatorOption.operator && operatorType === operatorOption.type; - }); - - return foundOperator ?? isOperator; -}; - -export const isEntryNested = ( - tbd: ExceptionEntry | NestedExceptionEntry -): tbd is NestedExceptionEntry => { - if (tbd.type === 'nested') { - return true; +export const getExceptionOperatorSelect = (entry: Entry): OperatorOption => { + if (entriesNested.is(entry)) { + return isOperator; + } else { + const operatorType = getOperatorType(entry); + const foundOperator = EXCEPTION_OPERATORS.find((operatorOption) => { + return entry.operator === operatorOption.operator && operatorType === operatorOption.type; + }); + + return foundOperator ?? isOperator; } - return false; }; /** @@ -71,11 +64,9 @@ export const isEntryNested = ( * * @param entries an ExceptionItem's entries */ -export const getFormattedEntries = ( - entries: Array -): FormattedEntry[] => { +export const getFormattedEntries = (entries: EntriesArray): FormattedEntry[] => { const formattedEntries = entries.map((entry) => { - if (isEntryNested(entry)) { + if (entriesNested.is(entry)) { const parent = { fieldName: entry.field, operator: null, value: null, isNested: false }; return entry.entries.reduce( (acc, nestedEntry) => { @@ -106,11 +97,10 @@ export const formatEntry = ({ }: { isNested: boolean; parent?: string; - item: ExceptionEntry; + item: Entry; }): FormattedEntry => { const operator = getExceptionOperatorSelect(item); - const operatorType = getOperatorType(item); - const value = operatorType === OperatorType.EXISTS ? null : item.value; + const value = !entriesExists.is(item) ? item.value : null; return { fieldName: isNested ? `${parent}.${item.field}` : item.field, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/mocks.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/mocks.ts deleted file mode 100644 index f5577560a680ac..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/mocks.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - Operator, - ExceptionListItemSchema, - ExceptionEntry, - NestedExceptionEntry, - FormattedEntry, -} from './types'; -import { ExceptionList } from '../../../lists_plugin_deps'; - -export const getExceptionListMock = (): ExceptionList => ({ - id: '5b543420', - created_at: '2020-04-23T00:19:13.289Z', - created_by: 'user_name', - list_id: 'test-exception', - tie_breaker_id: '77fd1909-6786-428a-a671-30229a719c1f', - updated_at: '2020-04-23T00:19:13.289Z', - updated_by: 'user_name', - namespace_type: 'single', - name: '', - description: 'This is a description', - _tags: ['os:windows'], - tags: [], - type: 'endpoint', - meta: {}, - totalItems: 0, -}); - -export const getExceptionItemEntryMock = (): ExceptionEntry => ({ - field: 'actingProcess.file.signer', - type: 'match', - operator: Operator.INCLUSION, - value: 'Elastic, N.V.', -}); - -export const getNestedExceptionItemEntryMock = (): NestedExceptionEntry => ({ - field: 'actingProcess.file.signer', - type: 'nested', - entries: [{ ...getExceptionItemEntryMock() }], -}); - -export const getFormattedEntryMock = (isNested = false): FormattedEntry => ({ - fieldName: 'host.name', - operator: 'is', - value: 'some name', - isNested, -}); - -export const getExceptionItemMock = (): ExceptionListItemSchema => ({ - id: 'uuid_here', - item_id: 'item-id', - created_at: '2020-04-23T00:19:13.289Z', - created_by: 'user_name', - list_id: 'test-exception', - tie_breaker_id: '77fd1909-6786-428a-a671-30229a719c1f', - updated_at: '2020-04-23T00:19:13.289Z', - updated_by: 'user_name', - namespace_type: 'single', - name: '', - description: 'This is a description', - comments: [ - { - created_by: 'user_name', - created_at: '2020-04-23T00:19:13.289Z', - comment: 'Comment goes here', - }, - ], - _tags: ['os:windows'], - tags: [], - type: 'simple', - entries: [ - { - field: 'actingProcess.file.signer', - type: 'match', - operator: Operator.INCLUSION, - value: 'Elastic, N.V.', - }, - { - field: 'host.name', - type: 'match', - operator: Operator.EXCLUSION, - value: 'Global Signer', - }, - { - field: 'file.signature', - type: 'nested', - entries: [ - { - field: 'signer', - type: 'match', - operator: Operator.INCLUSION, - value: 'Evil', - }, - { - field: 'trusted', - type: 'match', - operator: Operator.INCLUSION, - value: 'true', - }, - ], - }, - ], -}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/operators.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/operators.ts index 19c726893e6827..2c18d7447d5f6f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/operators.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/operators.ts @@ -5,15 +5,16 @@ */ import { i18n } from '@kbn/i18n'; -import { OperatorOption, OperatorType, Operator } from './types'; +import { OperatorOption } from './types'; +import { OperatorTypeEnum } from '../../../lists_plugin_deps'; export const isOperator: OperatorOption = { message: i18n.translate('xpack.securitySolution.exceptions.isOperatorLabel', { defaultMessage: 'is', }), value: 'is', - type: OperatorType.PHRASE, - operator: Operator.INCLUSION, + type: OperatorTypeEnum.MATCH, + operator: 'included', }; export const isNotOperator: OperatorOption = { @@ -21,8 +22,8 @@ export const isNotOperator: OperatorOption = { defaultMessage: 'is not', }), value: 'is_not', - type: OperatorType.PHRASE, - operator: Operator.EXCLUSION, + type: OperatorTypeEnum.MATCH, + operator: 'excluded', }; export const isOneOfOperator: OperatorOption = { @@ -30,8 +31,8 @@ export const isOneOfOperator: OperatorOption = { defaultMessage: 'is one of', }), value: 'is_one_of', - type: OperatorType.PHRASES, - operator: Operator.INCLUSION, + type: OperatorTypeEnum.MATCH_ANY, + operator: 'included', }; export const isNotOneOfOperator: OperatorOption = { @@ -39,8 +40,8 @@ export const isNotOneOfOperator: OperatorOption = { defaultMessage: 'is not one of', }), value: 'is_not_one_of', - type: OperatorType.PHRASES, - operator: Operator.EXCLUSION, + type: OperatorTypeEnum.MATCH_ANY, + operator: 'excluded', }; export const existsOperator: OperatorOption = { @@ -48,8 +49,8 @@ export const existsOperator: OperatorOption = { defaultMessage: 'exists', }), value: 'exists', - type: OperatorType.EXISTS, - operator: Operator.INCLUSION, + type: OperatorTypeEnum.EXISTS, + operator: 'included', }; export const doesNotExistOperator: OperatorOption = { @@ -57,8 +58,8 @@ export const doesNotExistOperator: OperatorOption = { defaultMessage: 'does not exist', }), value: 'does_not_exist', - type: OperatorType.EXISTS, - operator: Operator.EXCLUSION, + type: OperatorTypeEnum.EXISTS, + operator: 'excluded', }; export const isInListOperator: OperatorOption = { @@ -66,8 +67,8 @@ export const isInListOperator: OperatorOption = { defaultMessage: 'is in list', }), value: 'is_in_list', - type: OperatorType.LIST, - operator: Operator.INCLUSION, + type: OperatorTypeEnum.LIST, + operator: 'included', }; export const isNotInListOperator: OperatorOption = { @@ -75,8 +76,8 @@ export const isNotInListOperator: OperatorOption = { defaultMessage: 'is not in list', }), value: 'is_not_in_list', - type: OperatorType.LIST, - operator: Operator.EXCLUSION, + type: OperatorTypeEnum.LIST, + operator: 'excluded', }; export const EXCEPTION_OPERATORS: OperatorOption[] = [ diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index 86485e308b59f8..24c328462ce2fe 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -5,11 +5,7 @@ */ import { ReactNode } from 'react'; -import { - NamespaceType, - ExceptionList, - ExceptionListItemSchema as ExceptionItem, -} from '../../../lists_plugin_deps'; +import { Operator, OperatorType } from '../../../lists_plugin_deps'; export interface OperatorOption { message: string; @@ -18,39 +14,13 @@ export interface OperatorOption { type: OperatorType; } -export enum Operator { - INCLUSION = 'included', - EXCLUSION = 'excluded', -} - -export enum OperatorType { - NESTED = 'nested', - PHRASE = 'match', - PHRASES = 'match_any', - EXISTS = 'exists', - LIST = 'list', -} - export interface FormattedEntry { fieldName: string; operator: string | null; - value: string | null; + value: string | string[] | null; isNested: boolean; } -export interface NestedExceptionEntry { - field: string; - type: string; - entries: ExceptionEntry[]; -} - -export interface ExceptionEntry { - field: string; - type: string; - operator: Operator; - value: string; -} - export interface DescriptionListItem { title: NonNullable; description: NonNullable; @@ -79,47 +49,9 @@ export interface Filter { pagination: Partial; } -export interface SetExceptionsProps { - lists: ExceptionList[]; - exceptions: ExceptionItem[]; - pagination: Pagination; -} - -export interface ApiProps { - id: string; - namespaceType: NamespaceType; -} - -export interface Pagination { - page: number; - perPage: number; - total: number; -} - export interface ExceptionsPagination { pageIndex: number; pageSize: number; totalItemCount: number; pageSizeOptions: number[]; } - -// TODO: Delete once types are updated -export interface ExceptionListItemSchema { - _tags: string[]; - comments: Comment[]; - created_at: string; - created_by: string; - description?: string; - entries: Array; - id: string; - item_id: string; - list_id: string; - meta?: unknown; - name: string; - namespace_type: 'single' | 'agnostic'; - tags: string[]; - tie_breaker_id: string; - type: string; - updated_at: string; - updated_by: string; -} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx index b49615e24d052a..3ea8507d82a157 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx @@ -11,7 +11,8 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import moment from 'moment-timezone'; import { ExceptionDetails } from './exception_details'; -import { getExceptionItemMock } from '../../mocks'; +import { getExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { getCommentsMock } from '../../../../../../../lists/common/schemas/types/comments.mock'; describe('ExceptionDetails', () => { beforeEach(() => { @@ -23,7 +24,7 @@ describe('ExceptionDetails', () => { }); test('it renders no comments button if no comments exist', () => { - const exceptionItem = getExceptionItemMock(); + const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = []; const wrapper = mount( @@ -40,8 +41,8 @@ describe('ExceptionDetails', () => { }); test('it renders comments button if comments exist', () => { - const exceptionItem = getExceptionItemMock(); - + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.comments = getCommentsMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { }); test('it renders correct number of comments', () => { - const exceptionItem = getExceptionItemMock(); - + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.comments = [getCommentsMock()[0]]; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { }); test('it renders comments plural if more than one', () => { - const exceptionItem = getExceptionItemMock(); - exceptionItem.comments = [ - { - created_by: 'user_1', - created_at: '2020-04-23T00:19:13.289Z', - comment: 'Comment goes here', - }, - { - created_by: 'user_2', - created_at: '2020-04-23T00:19:13.289Z', - comment: 'Comment goes here', - }, - ]; + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.comments = getCommentsMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { }); test('it renders comments show text if "showComments" is false', () => { - const exceptionItem = getExceptionItemMock(); + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.comments = getCommentsMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { ); expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0).text()).toEqual( - 'Show (1) Comment' + 'Show (2) Comments' ); }); test('it renders comments hide text if "showComments" is true', () => { - const exceptionItem = getExceptionItemMock(); + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.comments = getCommentsMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { ); expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0).text()).toEqual( - 'Hide (1) Comment' + 'Hide (2) Comments' ); }); test('it invokes "onCommentsClick" when comments button clicked', () => { const mockOnCommentsClick = jest.fn(); - const exceptionItem = getExceptionItemMock(); + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.comments = getCommentsMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { }); test('it renders the operating system if one is specified in the exception item', () => { - const exceptionItem = getExceptionItemMock(); + const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { ); expect(wrapper.find('EuiDescriptionListTitle').at(0).text()).toEqual('OS'); - expect(wrapper.find('EuiDescriptionListDescription').at(0).text()).toEqual('Windows'); + expect(wrapper.find('EuiDescriptionListDescription').at(0).text()).toEqual('Linux'); }); test('it renders the exception item creator', () => { - const exceptionItem = getExceptionItemMock(); + const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { }); test('it renders the exception item creation timestamp', () => { - const exceptionItem = getExceptionItemMock(); + const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { }); test('it renders the description if one is included on the exception item', () => { - const exceptionItem = getExceptionItemMock(); + const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { expect(wrapper.find('EuiDescriptionListTitle').at(3).text()).toEqual('Comment'); expect(wrapper.find('EuiDescriptionListDescription').at(3).text()).toEqual( - 'This is a description' + 'This is a sample endpoint type exception' ); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx index 7144bf80447043..44632236ea7a04 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx @@ -15,9 +15,10 @@ import { import React, { useMemo, Fragment } from 'react'; import styled, { css } from 'styled-components'; -import { DescriptionListItem, ExceptionListItemSchema } from '../../types'; +import { DescriptionListItem } from '../../types'; import { getDescriptionListContent } from '../../helpers'; import * as i18n from '../../translations'; +import { ExceptionListItemSchema } from '../../../../../../public/lists_plugin_deps'; const MyExceptionDetails = styled(EuiFlexItem)` ${({ theme }) => css` diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx index 2d022591d99800..c6a779845b1906 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx @@ -10,7 +10,7 @@ import { mount } from 'enzyme'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { ExceptionEntries } from './exception_entries'; -import { getFormattedEntryMock } from '../../mocks'; +import { getFormattedEntryMock } from '../../exceptions.mock'; import { getEmptyValue } from '../../../empty_value'; describe('ExceptionEntries', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx index 58667f1f78b0d3..39e265acec1af0 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx @@ -12,6 +12,7 @@ import { EuiButton, EuiTableFieldDataColumnType, EuiHideFor, + EuiBadge, } from '@elastic/eui'; import React, { useMemo } from 'react'; import styled, { css } from 'styled-components'; @@ -44,6 +45,7 @@ const MyRemoveButton = styled(EuiButton)` const MyAndOrBadgeContainer = styled(EuiFlexItem)` padding-top: ${({ theme }) => theme.eui.euiSizeXL}; + padding-bottom: ${({ theme }) => theme.eui.euiSizeS}; `; interface ExceptionEntriesComponentProps { @@ -101,9 +103,13 @@ const ExceptionEntriesComponent = ({ render: (values: string | string[] | null) => { if (Array.isArray(values)) { return ( - + {values.map((value) => { - return {value}; + return ( + + {value} + + ); })} ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.stories.tsx index 3eb333966f267e..b5f18feb485025 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.stories.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.stories.tsx @@ -10,8 +10,8 @@ import { ThemeProvider } from 'styled-components'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { ExceptionItem } from './'; -import { Operator } from '../../types'; -import { getExceptionItemMock } from '../../mocks'; +import { getExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { getCommentsMock } from '../../../../../../../lists/common/schemas/types/comments.mock'; addDecorator((storyFn) => ( ({ eui: euiLightVars, darkMode: false })}>{storyFn()} @@ -19,14 +19,14 @@ addDecorator((storyFn) => ( storiesOf('Components|ExceptionItem', module) .add('with os', () => { - const payload = getExceptionItemMock(); + const payload = getExceptionListItemSchemaMock(); payload.description = ''; payload.comments = []; payload.entries = [ { field: 'actingProcess.file.signer', type: 'match', - operator: Operator.INCLUSION, + operator: 'included', value: 'Elastic, N.V.', }, ]; @@ -42,14 +42,14 @@ storiesOf('Components|ExceptionItem', module) ); }) .add('with description', () => { - const payload = getExceptionItemMock(); + const payload = getExceptionListItemSchemaMock(); payload._tags = []; payload.comments = []; payload.entries = [ { field: 'actingProcess.file.signer', type: 'match', - operator: Operator.INCLUSION, + operator: 'included', value: 'Elastic, N.V.', }, ]; @@ -65,14 +65,15 @@ storiesOf('Components|ExceptionItem', module) ); }) .add('with comments', () => { - const payload = getExceptionItemMock(); + const payload = getExceptionListItemSchemaMock(); payload._tags = []; payload.description = ''; + payload.comments = getCommentsMock(); payload.entries = [ { field: 'actingProcess.file.signer', type: 'match', - operator: Operator.INCLUSION, + operator: 'included', value: 'Elastic, N.V.', }, ]; @@ -88,7 +89,7 @@ storiesOf('Components|ExceptionItem', module) ); }) .add('with nested entries', () => { - const payload = getExceptionItemMock(); + const payload = getExceptionListItemSchemaMock(); payload._tags = []; payload.description = ''; payload.comments = []; @@ -104,8 +105,8 @@ storiesOf('Components|ExceptionItem', module) ); }) .add('with everything', () => { - const payload = getExceptionItemMock(); - + const payload = getExceptionListItemSchemaMock(); + payload.comments = getCommentsMock(); return ( { - const { id, namespace_type, ...rest } = getExceptionItemMock(); + const { id, namespace_type, ...rest } = getExceptionListItemSchemaMock(); return ( { it('it renders ExceptionDetails and ExceptionEntries', () => { - const exceptionItem = getExceptionItemMock(); + const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -34,7 +35,7 @@ describe('ExceptionItem', () => { it('it invokes "onEditException" when edit button clicked', () => { const mockOnEditException = jest.fn(); - const exceptionItem = getExceptionItemMock(); + const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -51,12 +52,12 @@ describe('ExceptionItem', () => { const editBtn = wrapper.find('[data-test-subj="exceptionsViewerEditBtn"] button').at(0); editBtn.simulate('click'); - expect(mockOnEditException).toHaveBeenCalledWith(getExceptionItemMock()); + expect(mockOnEditException).toHaveBeenCalledWith(getExceptionListItemSchemaMock()); }); it('it invokes "onDeleteException" when delete button clicked', () => { const mockOnDeleteException = jest.fn(); - const exceptionItem = getExceptionItemMock(); + const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -74,15 +75,15 @@ describe('ExceptionItem', () => { editBtn.simulate('click'); expect(mockOnDeleteException).toHaveBeenCalledWith({ - id: 'uuid_here', + id: '1', namespaceType: 'single', }); }); it('it renders comment accordion closed to begin with', () => { const mockOnDeleteException = jest.fn(); - const exceptionItem = getExceptionItemMock(); - + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.comments = getCommentsMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { it('it renders comment accordion open when showComments is true', () => { const mockOnDeleteException = jest.fn(); - const exceptionItem = getExceptionItemMock(); - + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.comments = getCommentsMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> void; + onDeleteException: (arg: ExceptionIdentifiers) => void; onEditException: (item: ExceptionListItemSchema) => void; } diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_item.test.tsx index dbcae20eb1385f..7ccb8d251eae10 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_item.test.tsx @@ -9,7 +9,7 @@ import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; -import { getExceptionItemMock } from '../mocks'; +import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { ExceptionsViewerItems } from './exceptions_viewer_items'; describe('ExceptionsViewerItems', () => { @@ -38,7 +38,7 @@ describe('ExceptionsViewerItems', () => { { }); it('it does not render or badge for first exception displayed', () => { - const exception1 = getExceptionItemMock(); - const exception2 = getExceptionItemMock(); + const exception1 = getExceptionListItemSchemaMock(); + const exception2 = getExceptionListItemSchemaMock(); exception2.id = 'newId'; const wrapper = mount( @@ -95,8 +95,8 @@ describe('ExceptionsViewerItems', () => { }); it('it does render or badge with exception displayed', () => { - const exception1 = getExceptionItemMock(); - const exception2 = getExceptionItemMock(); + const exception1 = getExceptionListItemSchemaMock(); + const exception2 = getExceptionListItemSchemaMock(); exception2.id = 'newId'; const wrapper = mount( @@ -128,7 +128,7 @@ describe('ExceptionsViewerItems', () => { { wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0).simulate('click'); expect(mockOnDeleteException).toHaveBeenCalledWith({ - id: 'uuid_here', + id: '1', namespaceType: 'single', }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx index e1ef3c10188b31..63137a7b248994 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx @@ -9,9 +9,12 @@ import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/e import styled from 'styled-components'; import * as i18n from '../translations'; -import { ExceptionListItemSchema, ApiProps } from '../types'; import { ExceptionItem } from './exception_item'; import { AndOrBadge } from '../../and_or_badge'; +import { + ExceptionIdentifiers, + ExceptionListItemSchema, +} from '../../../../../public/lists_plugin_deps'; const MyFlexItem = styled(EuiFlexItem)` margin: ${({ theme }) => `${theme.eui.euiSize} 0`}; @@ -34,9 +37,9 @@ interface ExceptionsViewerItemsProps { showEmpty: boolean; isInitLoading: boolean; exceptions: ExceptionListItemSchema[]; - loadingItemIds: ApiProps[]; + loadingItemIds: ExceptionIdentifiers[]; commentsAccordionId: string; - onDeleteException: (arg: ApiProps) => void; + onDeleteException: (arg: ExceptionIdentifiers) => void; onEditExceptionItem: (item: ExceptionListItemSchema) => void; } diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx index b77b8380c39f1a..3a1e3f85b11fd7 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx @@ -13,7 +13,7 @@ import { ExceptionsViewer } from './'; import { ExceptionListType } from '../types'; import { useKibana } from '../../../../common/lib/kibana'; import { useExceptionList, useApi } from '../../../../../public/lists_plugin_deps'; -import { getExceptionListMock } from '../mocks'; +import { getExceptionListSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_schema.mock'; jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../../public/lists_plugin_deps'); @@ -96,7 +96,7 @@ describe('ExceptionsViewer', () => { it('it renders empty prompt if no exception items exist', () => { (useExceptionList as jest.Mock).mockReturnValue([ false, - [getExceptionListMock()], + [getExceptionListSchemaMock()], [], { page: 1, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index 10519628522193..79ed8bb37949f5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -14,17 +14,13 @@ import { useKibana } from '../../../../common/lib/kibana'; import { Panel } from '../../../../common/components/panel'; import { Loader } from '../../../../common/components/loader'; import { ExceptionsViewerHeader } from './exceptions_viewer_header'; -import { - ExceptionListType, - ExceptionListItemSchema, - ApiProps, - Filter, - SetExceptionsProps, -} from '../types'; +import { ExceptionListType, Filter } from '../types'; import { allExceptionItemsReducer, State } from './reducer'; import { useExceptionList, ExceptionIdentifiers, + ExceptionListItemSchema, + UseExceptionListSuccess, useApi, } from '../../../../../public/lists_plugin_deps'; import { ExceptionsViewerPagination } from './exceptions_pagination'; @@ -107,11 +103,11 @@ const ExceptionsViewerComponent = ({ lists: newLists, exceptions: newExceptions, pagination: newPagination, - }: SetExceptionsProps) => { + }: UseExceptionListSuccess) => { dispatch({ type: 'setExceptions', lists: newLists, - exceptions: (newExceptions as unknown) as ExceptionListItemSchema[], + exceptions: newExceptions, pagination: newPagination, }); }, @@ -199,7 +195,7 @@ const ExceptionsViewerComponent = ({ ); const setLoadingItemIds = useCallback( - (items: ApiProps[]): void => { + (items: ExceptionIdentifiers[]): void => { dispatch({ type: 'updateLoadingItemIds', items, @@ -209,7 +205,7 @@ const ExceptionsViewerComponent = ({ ); const handleDeleteException = useCallback( - ({ id, namespaceType }: ApiProps) => { + ({ id, namespaceType }: ExceptionIdentifiers) => { setLoadingItemIds([{ id, namespaceType }]); deleteExceptionItem({ diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts index 538207458f0edb..1f9a4fb446ab82 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts @@ -3,14 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { FilterOptions, ExceptionsPagination } from '../types'; import { - ApiProps, - FilterOptions, - ExceptionsPagination, + ExceptionList, ExceptionListItemSchema, + ExceptionIdentifiers, Pagination, -} from '../types'; -import { ExceptionList, ExceptionIdentifiers } from '../../../../../public/lists_plugin_deps'; +} from '../../../../../public/lists_plugin_deps'; export interface State { filterOptions: FilterOptions; @@ -21,7 +20,7 @@ export interface State { exceptions: ExceptionListItemSchema[]; exceptionToEdit: ExceptionListItemSchema | null; loadingLists: ExceptionIdentifiers[]; - loadingItemIds: ApiProps[]; + loadingItemIds: ExceptionIdentifiers[]; isInitLoading: boolean; isModalOpen: boolean; } @@ -42,7 +41,7 @@ export type Action = | { type: 'updateIsInitLoading'; loading: boolean } | { type: 'updateModalOpen'; isOpen: boolean } | { type: 'updateExceptionToEdit'; exception: ExceptionListItemSchema } - | { type: 'updateLoadingItemIds'; items: ApiProps[] }; + | { type: 'updateLoadingItemIds'; items: ExceptionIdentifiers[] }; export const allExceptionItemsReducer = () => (state: State, action: Action): State => { switch (action.type) { @@ -62,7 +61,7 @@ export const allExceptionItemsReducer = () => (state: State, action: Action): St ...state.pagination, pageIndex: action.pagination.page - 1, pageSize: action.pagination.perPage, - totalItemCount: action.pagination.total, + totalItemCount: action.pagination.total ?? 0, }, allExceptions: action.exceptions, exceptions: action.exceptions, diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/view/details/metadata/file_accordion.tsx b/x-pack/plugins/security_solution/public/endpoint_alerts/view/details/metadata/file_accordion.tsx index 6c9df46c9313c4..82800c92e742c1 100644 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/view/details/metadata/file_accordion.tsx +++ b/x-pack/plugins/security_solution/public/endpoint_alerts/view/details/metadata/file_accordion.tsx @@ -74,7 +74,7 @@ export const FileAccordion = memo(({ alertData }: { alertData: Immutable { } else if (columnId === 'archived') { return null; } else if (columnId === 'malware_score') { - return row.file.malware_classification.score; + return row.file.Ext.malware_classification.score; } return null; }, diff --git a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts index 113bfaa860f000..22732c86bd9a9e 100644 --- a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts +++ b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts @@ -11,12 +11,20 @@ export { usePersistExceptionList, ExceptionIdentifiers, ExceptionList, - mockNewExceptionItem, - mockNewExceptionList, + Pagination, + UseExceptionListSuccess, } from '../../lists/public'; export { ExceptionListSchema, ExceptionListItemSchema, - Entries, + Entry, + EntryExists, + EntryNested, + EntriesArray, NamespaceType, + Operator, + OperatorType, + OperatorTypeEnum, + entriesNested, + entriesExists, } from '../../lists/common/schemas'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 5e7cbc0ef58d34..20365b3fe100b9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -41,7 +41,7 @@ export const detailsError = (state: Immutable) => state.detailsError; * Returns the full policy response from the endpoint after a user modifies a policy. */ const detailsPolicyAppliedResponse = (state: Immutable) => - state.policyResponse && state.policyResponse.endpoint.policy.applied; + state.policyResponse && state.policyResponse.Endpoint.policy.applied; /** * Returns the response configurations from the endpoint after a user modifies a policy. @@ -179,6 +179,6 @@ export const showView: (state: HostState) => 'policy_response' | 'details' = cre export const policyResponseStatus: (state: Immutable) => string = createSelector( (state) => state.policyResponse, (policyResponse) => { - return (policyResponse && policyResponse?.endpoint?.policy?.applied?.status) || ''; + return (policyResponse && policyResponse?.Endpoint?.policy?.applied?.status) || ''; } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx index 9ec65a5d17898c..f31b54b93851f0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx @@ -97,15 +97,15 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { return [ getManagementUrl({ name: 'policyDetails', - policyId: details.endpoint.policy.applied.id, + policyId: details.Endpoint.policy.applied.id, excludePrefix: true, }), getManagementUrl({ name: 'policyDetails', - policyId: details.endpoint.policy.applied.id, + policyId: details.Endpoint.policy.applied.id, }), ]; - }, [details.endpoint.policy.applied.id]); + }, [details.Endpoint.policy.applied.id]); const policyDetailsClickHandler = useNavigateByRouterEventHandler(policyDetailsRoutePath); @@ -123,7 +123,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { href={policyDetailsRouteUrl} onClick={policyDetailsClickHandler} > - {details.endpoint.policy.applied.name} + {details.Endpoint.policy.applied.name} ), diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index e0f797b1430551..af027c2e106c3d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -50,13 +50,13 @@ describe('when on the hosts page', () => { }); describe('when list data loads', () => { const generatedPolicyStatuses: Array< - HostInfo['metadata']['endpoint']['policy']['applied']['status'] + HostInfo['metadata']['Endpoint']['policy']['applied']['status'] > = []; let firstPolicyID: string; beforeEach(() => { reactTestingLibrary.act(() => { const hostListData = mockHostResultList({ total: 3 }); - firstPolicyID = hostListData.hosts[0].metadata.endpoint.policy.applied.id; + firstPolicyID = hostListData.hosts[0].metadata.Endpoint.policy.applied.id; [HostStatus.ERROR, HostStatus.ONLINE, HostStatus.OFFLINE].forEach((status, index) => { hostListData.hosts[index] = { metadata: hostListData.hosts[index].metadata, @@ -64,7 +64,7 @@ describe('when on the hosts page', () => { }; }); hostListData.hosts.forEach((item, index) => { - generatedPolicyStatuses[index] = item.metadata.endpoint.policy.applied.status; + generatedPolicyStatuses[index] = item.metadata.Endpoint.policy.applied.status; }); const action: AppAction = { type: 'serverReturnedHostList', @@ -160,9 +160,11 @@ describe('when on the hosts page', () => { overallStatus: HostPolicyResponseActionStatus = HostPolicyResponseActionStatus.success ) => { const policyResponse = docGenerator.generatePolicyResponse(); - policyResponse.endpoint.policy.applied.status = overallStatus; - policyResponse.endpoint.policy.applied.response.configurations.malware.status = overallStatus; - let downloadModelAction = policyResponse.endpoint.policy.applied.actions.find( + const malwareResponseConfigurations = + policyResponse.Endpoint.policy.applied.response.configurations.malware; + policyResponse.Endpoint.policy.applied.status = overallStatus; + malwareResponseConfigurations.status = overallStatus; + let downloadModelAction = policyResponse.Endpoint.policy.applied.actions.find( (action) => action.name === 'download_model' ); @@ -172,19 +174,30 @@ describe('when on the hosts page', () => { message: 'Failed to apply a portion of the configuration (kernel)', status: overallStatus, }; - policyResponse.endpoint.policy.applied.actions.push(downloadModelAction); + policyResponse.Endpoint.policy.applied.actions.push(downloadModelAction); } + if ( overallStatus === HostPolicyResponseActionStatus.failure || overallStatus === HostPolicyResponseActionStatus.warning ) { downloadModelAction.message = 'no action taken'; } - store.dispatch({ - type: 'serverReturnedHostPolicyResponse', - payload: { - policy_response: policyResponse, - }, + + // Make sure that at least one configuration has the above action, else + // we get into an out-of-sync condition + if ( + malwareResponseConfigurations.concerned_actions.indexOf(downloadModelAction.name) === -1 + ) { + malwareResponseConfigurations.concerned_actions.push(downloadModelAction.name); + } + reactTestingLibrary.act(() => { + store.dispatch({ + type: 'serverReturnedHostPolicyResponse', + payload: { + policy_response: policyResponse, + }, + }); }); }; @@ -236,7 +249,7 @@ describe('when on the hosts page', () => { const policyDetailsLink = await renderResult.findByTestId('policyDetailsValue'); expect(policyDetailsLink).not.toBeNull(); expect(policyDetailsLink.getAttribute('href')).toEqual( - `#/management/policy/${hostDetails.metadata.endpoint.policy.applied.id}` + `#/management/policy/${hostDetails.metadata.Endpoint.policy.applied.id}` ); }); @@ -252,7 +265,7 @@ describe('when on the hosts page', () => { }); const changedUrlAction = await userChangedUrlChecker; expect(changedUrlAction.payload.pathname).toEqual( - `/management/policy/${hostDetails.metadata.endpoint.policy.applied.id}` + `/management/policy/${hostDetails.metadata.Endpoint.policy.applied.id}` ); }); @@ -361,6 +374,12 @@ describe('when on the hosts page', () => { describe('when showing host Policy Response panel', () => { let renderResult: ReturnType; beforeEach(async () => { + coreStart.http.post.mockImplementation(async (requestOptions) => { + if (requestOptions.path === '/api/endpoint/metadata') { + return mockHostResultList({ total: 0 }); + } + throw new Error(`POST to '${requestOptions.path}' does not have a mock response!`); + }); renderResult = render(); const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); const userChangedUrlChecker = middlewareSpy.waitForAction('userChangedUrl'); @@ -373,6 +392,8 @@ describe('when on the hosts page', () => { }); }); + afterEach(reactTestingLibrary.cleanup); + it('should hide the host details panel', async () => { const hostDetailsFlyout = await renderResult.queryByTestId('hostDetailsFlyoutBody'); expect(hostDetailsFlyout).toBeNull(); @@ -433,21 +454,29 @@ describe('when on the hosts page', () => { }); }); - it('should show a numbered badge if at least one action failed', () => { + it('should show a numbered badge if at least one action failed', async () => { + const policyResponseActionDispatched = middlewareSpy.waitForAction( + 'serverReturnedHostPolicyResponse' + ); reactTestingLibrary.act(() => { dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.failure); }); - const attentionBadge = renderResult.findAllByTestId( + await policyResponseActionDispatched; + const attentionBadge = await renderResult.findAllByTestId( 'hostDetailsPolicyResponseAttentionBadge' ); expect(attentionBadge).not.toBeNull(); }); - it('should show a numbered badge if at least one action has a warning', () => { + it('should show a numbered badge if at least one action has a warning', async () => { + const policyResponseActionDispatched = middlewareSpy.waitForAction( + 'serverReturnedHostPolicyResponse' + ); reactTestingLibrary.act(() => { dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.warning); }); - const attentionBadge = renderResult.findAllByTestId( + await policyResponseActionDispatched; + const attentionBadge = await renderResult.findAllByTestId( 'hostDetailsPolicyResponseAttentionBadge' ); expect(attentionBadge).not.toBeNull(); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index c67c29fbc73a90..149819480855bd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -154,13 +154,13 @@ export const HostList = () => { }, }, { - field: 'metadata.endpoint.policy.applied', + field: 'metadata.Endpoint.policy.applied', name: i18n.translate('xpack.securitySolution.endpointList.policy', { defaultMessage: 'Policy', }), truncateText: true, // eslint-disable-next-line react/display-name - render: (policy: HostInfo['metadata']['endpoint']['policy']['applied']) => { + render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied']) => { const toRoutePath = getManagementUrl({ name: 'policyDetails', policyId: policy.id, @@ -181,12 +181,12 @@ export const HostList = () => { }, }, { - field: 'metadata.endpoint.policy.applied', + field: 'metadata.Endpoint.policy.applied', name: i18n.translate('xpack.securitySolution.endpointList.policyStatus', { defaultMessage: 'Policy Status', }), // eslint-disable-next-line react/display-name - render: (policy: HostInfo['metadata']['endpoint']['policy']['applied'], item: HostInfo) => { + render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => { const toRoutePath = getManagementUrl({ name: 'endpointPolicyResponse', selected_host: item.metadata.host.id, diff --git a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts index e4460b337960fd..542991a927f7ab 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts @@ -8,10 +8,9 @@ import * as path from 'path'; import yargs from 'yargs'; import * as url from 'url'; import fetch from 'node-fetch'; -import seedrandom from 'seedrandom'; import { Client, ClientOptions } from '@elastic/elasticsearch'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; -import { EndpointDocGenerator, Event } from '../../common/endpoint/generate_data'; +import { indexHostsAndAlerts } from '../../common/endpoint/index_data'; main(); @@ -201,59 +200,26 @@ async function main() { seed = Math.random().toString(); console.log(`No seed supplied, using random seed: ${seed}`); } - const random = seedrandom(seed); const startTime = new Date().getTime(); - for (let i = 0; i < argv.numHosts; i++) { - const generator = new EndpointDocGenerator(random); - const timeBetweenDocs = 6 * 3600 * 1000; // 6 hours between metadata documents - - const timestamp = new Date().getTime(); - for (let j = 0; j < argv.numDocs; j++) { - generator.updateHostData(); - generator.updatePolicyId(); - await client.index({ - index: argv.metadataIndex, - body: generator.generateHostMetadata(timestamp - timeBetweenDocs * (argv.numDocs - j - 1)), - op_type: 'create', - }); - await client.index({ - index: argv.policyIndex, - body: generator.generatePolicyResponse( - timestamp - timeBetweenDocs * (argv.numDocs - j - 1) - ), - op_type: 'create', - }); + await indexHostsAndAlerts( + client, + seed, + argv.numHosts, + argv.numDocs, + argv.metadataIndex, + argv.policyIndex, + argv.eventIndex, + argv.alertsPerHost, + { + ancestors: argv.ancestors, + generations: argv.generations, + children: argv.children, + relatedEvents: argv.relatedEvents, + relatedAlerts: argv.relatedAlerts, + percentWithRelated: argv.percentWithRelated, + percentTerminated: argv.percentTerminated, + alwaysGenMaxChildrenPerNode: argv.maxChildrenPerNode, } - - const alertGenerator = generator.alertsGenerator( - argv.alertsPerHost, - argv.ancestors, - argv.generations, - argv.children, - argv.relatedEvents, - argv.relatedAlerts, - argv.percentWithRelated, - argv.percentTerminated, - argv.maxChildrenPerNode - ); - let result = alertGenerator.next(); - while (!result.done) { - let k = 0; - const resolverDocs: Event[] = []; - while (k < 1000 && !result.done) { - resolverDocs.push(result.value); - result = alertGenerator.next(); - k++; - } - const body = resolverDocs.reduce( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (array: Array>, doc) => ( - array.push({ create: { _index: argv.eventIndex } }, doc), array - ), - [] - ); - await client.bulk({ body, refresh: 'true' }); - } - } + ); console.log(`Creating and indexing documents took: ${new Date().getTime() - startTime}ms`); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index 80626bbdb6e7fc..92835dc5329ce3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -237,7 +237,7 @@ describe('test endpoint route', () => { expect(routeConfig.options).toEqual({ authRequired: true }); expect(mockResponse.ok).toBeCalled(); const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; - expect(result).toHaveProperty('metadata.endpoint'); + expect(result).toHaveProperty('metadata.Endpoint'); expect(result.host_status).toEqual(HostStatus.ONLINE); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index fe66496f70dcdf..9928ce4807da9a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -549,6 +549,7 @@ export const getFindResultStatus = (): SavedObjectsFindResponse< searchAfterTimeDurations: ['200.00'], bulkCreateTimeDurations: ['800.43'], }, + score: 1, references: [], updated_at: '2020-02-18T15:26:51.333Z', version: 'WzQ2LDFd', @@ -570,6 +571,7 @@ export const getFindResultStatus = (): SavedObjectsFindResponse< searchAfterTimeDurations: ['200.00'], bulkCreateTimeDurations: ['800.43'], }, + score: 1, references: [], updated_at: '2020-02-18T15:15:58.860Z', version: 'WzMyLDFd', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index 84148231431a16..a1cf9ccc45f387 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -254,16 +254,14 @@ describe('import_rules_route', () => { errors: [ { error: { - // TODO: Change the formatter to do better than output [object Object] - message: '[object Object]', + message: 'Invalid value "undefined" supplied to "rule_id"', status_code: 400, }, rule_id: '(unknown id)', }, { error: { - // TODO: Change the formatter to do better than output [object Object] - message: '[object Object]', + message: 'Invalid value "undefined" supplied to "rule_id"', status_code: 400, }, rule_id: '(unknown id)', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts index 73d3c65774b3da..c4d7df61061bdf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts @@ -365,9 +365,8 @@ describe('create_rules_stream_from_ndjson', () => { references: [], version: 1, }); - // TODO: Change the formatter to output something better than [object Object] expect(resultOrError[1].message).toEqual( - '[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]' + 'Invalid value "undefined" supplied to "description",Invalid value "undefined" supplied to "risk_score",Invalid value "undefined" supplied to "name",Invalid value "undefined" supplied to "severity",Invalid value "undefined" supplied to "type",Invalid value "undefined" supplied to "rule_id"' ); expect(resultOrError[2]).toEqual({ actions: [], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts index 932a4ef9eed924..d7723232ca921d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts @@ -7,6 +7,7 @@ import { Transform } from 'stream'; import * as t from 'io-ts'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; +import { formatErrors } from '../../../../common/format_errors'; import { importRuleValidateTypeDependents } from '../../../../common/detection_engine/schemas/request/import_rules_type_dependents'; import { exactCheck } from '../../../../common/exact_check'; import { @@ -32,7 +33,7 @@ export const validateRules = (): Transform => { const decoded = importRulesSchema.decode(obj); const checked = exactCheck(obj, decoded); const onLeft = (errors: t.Errors): BadRequestError | ImportRulesSchemaDecoded => { - return new BadRequestError(errors.join()); + return new BadRequestError(formatErrors(errors).join()); }; const onRight = (schema: ImportRulesSchema): BadRequestError | ImportRulesSchemaDecoded => { const validationErrors = importRuleValidateTypeDependents(schema); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts index 597a74f6efbbda..ed1a239facf792 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.test.ts @@ -33,16 +33,14 @@ describe('get_existing_prepackaged_rules', () => { }); test('should throw an exception if a pre-packaged rule is not valid', () => { - // TODO: Improve the error formatter around [object Object] expect(() => getPrepackagedRules([{ not_valid_made_up_key: true }])).toThrow( - 'name: "(rule name unknown)", rule_id: "(rule rule_id unknown)" within the folder rules/prepackaged_rules is not a valid detection engine rule. Expect the system to not work with pre-packaged rules until this rule is fixed or the file is removed. Error is: [object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object], Full rule contents are:\n{\n "not_valid_made_up_key": true\n}' + 'name: "(rule name unknown)", rule_id: "(rule rule_id unknown)" within the folder rules/prepackaged_rules is not a valid detection engine rule. Expect the system to not work with pre-packaged rules until this rule is fixed or the file is removed. Error is: Invalid value "undefined" supplied to "description",Invalid value "undefined" supplied to "risk_score",Invalid value "undefined" supplied to "name",Invalid value "undefined" supplied to "severity",Invalid value "undefined" supplied to "type",Invalid value "undefined" supplied to "rule_id",Invalid value "undefined" supplied to "version", Full rule contents are:\n{\n "not_valid_made_up_key": true\n}' ); }); test('should throw an exception with a message having rule_id and name in it', () => { - // TODO: Improve the error formatter around [object Object] expect(() => getPrepackagedRules([{ name: 'rule name', rule_id: 'id-123' }])).toThrow( - 'name: "rule name", rule_id: "id-123" within the folder rules/prepackaged_rules is not a valid detection engine rule. Expect the system to not work with pre-packaged rules until this rule is fixed or the file is removed. Error is: [object Object],[object Object],[object Object],[object Object],[object Object], Full rule contents are:\n{\n "name": "rule name",\n "rule_id": "id-123"\n}' + 'name: "rule name", rule_id: "id-123" within the folder rules/prepackaged_rules is not a valid detection engine rule. Expect the system to not work with pre-packaged rules until this rule is fixed or the file is removed. Error is: Invalid value "undefined" supplied to "description",Invalid value "undefined" supplied to "risk_score",Invalid value "undefined" supplied to "severity",Invalid value "undefined" supplied to "type",Invalid value "undefined" supplied to "version", Full rule contents are:\n{\n "name": "rule name",\n "rule_id": "id-123"\n}' ); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts index d2af93c3296363..354f8b90fae23e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts @@ -7,6 +7,7 @@ import * as t from 'io-ts'; import { fold } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; +import { formatErrors } from '../../../../common/format_errors'; import { exactCheck } from '../../../../common/exact_check'; import { addPrepackagedRulesSchema, @@ -35,11 +36,9 @@ export const validateAllPrepackagedRules = ( `name: "${ruleName}", rule_id: "${ruleId}" within the folder rules/prepackaged_rules ` + `is not a valid detection engine rule. Expect the system ` + `to not work with pre-packaged rules until this rule is fixed ` + - `or the file is removed. Error is: ${errors.join()}, Full rule contents are:\n${JSON.stringify( - rule, - null, - 2 - )}` + `or the file is removed. Error is: ${formatErrors( + errors + ).join()}, Full rule contents are:\n${JSON.stringify(rule, null, 2)}` ); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 6056e692854afe..01ee41e3b877c9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -391,7 +391,7 @@ export const exampleFindRuleStatusResponse: ( total: 1, per_page: 6, page: 1, - saved_objects: mockStatuses, + saved_objects: mockStatuses.map((obj) => ({ ...obj, score: 1 })), }); export const mockLogger: Logger = loggingServiceMock.createLogger(); diff --git a/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts index f455ac0696e4e4..0eb021bfe2a83d 100644 --- a/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts @@ -8,6 +8,7 @@ import { has, isString } from 'lodash/fp'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; +import { formatErrors } from '../../../common/format_errors'; import { importRuleValidateTypeDependents } from '../../../common/detection_engine/schemas/request/import_rules_type_dependents'; import { ImportRulesSchemaDecoded, @@ -47,7 +48,7 @@ export const validateRules = (): Transform => { const decoded = importRulesSchema.decode(obj); const checked = exactCheck(obj, decoded); const onLeft = (errors: t.Errors): BadRequestError | ImportRulesSchemaDecoded => { - return new BadRequestError(errors.join()); + return new BadRequestError(formatErrors(errors).join()); }; const onRight = (schema: ImportRulesSchema): BadRequestError | ImportRulesSchemaDecoded => { const validationErrors = importRuleValidateTypeDependents(schema); diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index 09fc990e9935c6..53f5a219dda5b9 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -14,7 +14,7 @@ import { createResolveSavedObjectsImportErrorsMock, createMockSavedObjectsService, } from '../__fixtures__'; -import { CoreSetup, IRouter, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; +import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; import { loggingServiceMock, httpServiceMock, @@ -54,7 +54,7 @@ describe('copy to space', () => { const setup = async () => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts index 774b794d77e29d..f31ef657642e74 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -13,7 +13,6 @@ import { } from '../__fixtures__'; import { CoreSetup, - IRouter, kibanaResponseFactory, RouteValidatorConfig, SavedObjectsErrorHelpers, @@ -37,7 +36,7 @@ describe('Spaces Public API', () => { const setup = async () => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts index 19f9b81baa0b08..55e153cf47f5bc 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts @@ -11,7 +11,7 @@ import { mockRouteContext, } from '../__fixtures__'; import { initGetSpaceApi } from './get'; -import { CoreSetup, IRouter, kibanaResponseFactory } from 'src/core/server'; +import { CoreSetup, kibanaResponseFactory } from 'src/core/server'; import { loggingServiceMock, httpServiceMock, @@ -30,7 +30,7 @@ describe('GET space', () => { const setup = async () => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); const coreStart = coreMock.createStart(); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts index 380cc9dbe5abfc..aabd4900c5469b 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -10,7 +10,7 @@ import { mockRouteContext, mockRouteContextWithInvalidLicense, } from '../__fixtures__'; -import { CoreSetup, kibanaResponseFactory, IRouter } from 'src/core/server'; +import { CoreSetup, kibanaResponseFactory } from 'src/core/server'; import { loggingServiceMock, httpServiceMock, @@ -30,7 +30,7 @@ describe('GET /spaces/space', () => { const setup = async () => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); const coreStart = coreMock.createStart(); diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts index ca3afc04b9798e..5e09308f07d312 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts @@ -10,7 +10,7 @@ import { mockRouteContext, mockRouteContextWithInvalidLicense, } from '../__fixtures__'; -import { CoreSetup, kibanaResponseFactory, IRouter, RouteValidatorConfig } from 'src/core/server'; +import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; import { loggingServiceMock, httpServerMock, @@ -30,7 +30,7 @@ describe('Spaces Public API', () => { const setup = async () => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); const coreStart = coreMock.createStart(); diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts index 62444fd3e4dfd6..7b068d37840438 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts @@ -11,7 +11,7 @@ import { mockRouteContext, mockRouteContextWithInvalidLicense, } from '../__fixtures__'; -import { CoreSetup, IRouter, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; +import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; import { loggingServiceMock, httpServiceMock, @@ -31,7 +31,7 @@ describe('PUT /api/spaces/space', () => { const setup = async () => { const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter('') as jest.Mocked; + const router = httpService.createRouter(); const coreStart = coreMock.createStart(); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 75cd501a1a9aec..190429d2dacd4d 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -138,7 +138,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; test(`passes options.type to baseClient if valid singular type specified`, async () => { const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = { - saved_objects: [createMockResponse()], + saved_objects: [createMockResponse()].map((obj) => ({ ...obj, score: 1 })), total: 1, per_page: 0, page: 0, @@ -158,7 +158,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; test(`supplements options with the current namespace`, async () => { const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = { - saved_objects: [createMockResponse()], + saved_objects: [createMockResponse()].map((obj) => ({ ...obj, score: 1 })), total: 1, per_page: 0, page: 0, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7b887867b43b85..5ba2541cbb050a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3970,6 +3970,27 @@ "xpack.uiActionsEnhanced.customizeTimeRangeMenuItem.displayName": "時間範囲のカスタマイズ", "xpack.uiActionsEnhanced.components.DiscoverDrilldownConfig.chooseIndexPattern": "対象インデックスパターンを選択", "xpack.uiActionsEnhanced.drilldown.goToDiscover": "Discoverに移動(例)", + "xpack.uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.helpText": "ドロップダウンを使用すると、パネルを操作するときに、新しい動作を定義できます。複数のオプションを追加するか、既定のフィルタリング動作を上書きできます。", + "xpack.uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "非表示", + "xpack.uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "ドキュメントを表示", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel": "ドリルダウンを作成", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "ドリルダウンを作成", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "ドリルダウンを削除", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel": "保存", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "ドリルダウンを編集", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "ドリルダウンを削除しました。", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "ドリルダウンが削除されました", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "ドリルダウンの保存エラー", + "xpack.uiActionsEnhanced.drilldowns.components.FlyoutFrame.BackButtonLabel": "戻る", + "xpack.uiActionsEnhanced.drilldowns.components.FlyoutFrame.CloseButtonLabel": "閉じる", + "xpack.uiActionsEnhanced.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle": "ドリルダウンを管理", + "xpack.uiActionsEnhanced.drilldowns.components.FormCreateDrilldown.drilldownAction": "アクション", + "xpack.uiActionsEnhanced.drilldowns.components.FormCreateDrilldown.nameOfDrilldown": "名前", + "xpack.uiActionsEnhanced.drilldowns.components.FormCreateDrilldown.untitledDrilldown": "無題のドリルダウン", + "xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel": "新規作成...", + "xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel": "削除({count})", + "xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel": "編集", + "xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel": "このドリルダウンを選択", "xpack.alerts.alertNavigationRegistry.get.missingNavigationError": "「{consumer}」内のアラートタイプ「{alertType}」のナビゲーションは登録されていません。", "xpack.alerts.alertNavigationRegistry.register.duplicateDefaultError": "「{consumer}」内のデフォルトナビゲーションは既に登録されています。", "xpack.alerts.alertNavigationRegistry.register.duplicateNavigationError": "「{consumer}」内のアラートタイプ「{alertType}」のナビゲーションは既に登録されています。", @@ -6122,27 +6143,6 @@ "xpack.data.kueryAutocomplete.orOperatorDescription.oneOrMoreArgumentsText": "1つ以上の引数", "xpack.data.query.queryBar.cancelLongQuery": "キャンセル", "xpack.data.query.queryBar.runBeyond": "タイムアウトを越えて実行", - "xpack.drilldowns.components.DrilldownHelloBar.helpText": "ドロップダウンを使用すると、パネルを操作するときに、新しい動作を定義できます。複数のオプションを追加するか、既定のフィルタリング動作を上書きできます。", - "xpack.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "非表示", - "xpack.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "ドキュメントを表示", - "xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel": "ドリルダウンを作成", - "xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "ドリルダウンを作成", - "xpack.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "ドリルダウンを削除", - "xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel": "保存", - "xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "ドリルダウンを編集", - "xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "ドリルダウンを削除しました。", - "xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "ドリルダウンが削除されました", - "xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "ドリルダウンの保存エラー", - "xpack.drilldowns.components.FlyoutFrame.BackButtonLabel": "戻る", - "xpack.drilldowns.components.FlyoutFrame.CloseButtonLabel": "閉じる", - "xpack.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle": "ドリルダウンを管理", - "xpack.drilldowns.components.FormCreateDrilldown.drilldownAction": "アクション", - "xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown": "名前", - "xpack.drilldowns.components.FormCreateDrilldown.untitledDrilldown": "無題のドリルダウン", - "xpack.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel": "新規作成...", - "xpack.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel": "削除({count})", - "xpack.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel": "編集", - "xpack.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel": "このドリルダウンを選択", "xpack.features.advancedSettingsFeatureName": "高度な設定", "xpack.features.dashboardFeatureName": "ダッシュボード", "xpack.features.devToolsFeatureName": "開発ツール", @@ -7080,12 +7080,9 @@ "xpack.idxMgmt.templateEdit.managedTemplateWarningTitle": "マネジドテンプレートの編集は許可されていません。", "xpack.idxMgmt.templateEdit.systemTemplateWarningDescription": "システムテンプレートは内部オペレーションに不可欠です。", "xpack.idxMgmt.templateEdit.systemTemplateWarningTitle": "システムテンプレートを編集することで、Kibana に重大な障害が生じる可能性があります", - "xpack.idxMgmt.templateForm.backButtonLabel": "戻る", "xpack.idxMgmt.templateForm.createButtonLabel": "テンプレートを作成", - "xpack.idxMgmt.templateForm.nextButtonLabel": "次へ", "xpack.idxMgmt.templateForm.saveButtonLabel": "テンプレートを保存", "xpack.idxMgmt.templateForm.saveTemplateError": "テンプレートを作成できません", - "xpack.idxMgmt.templateForm.savingButtonLabel": "保存中…", "xpack.idxMgmt.templateForm.stepAliases.aliasesDescription": "エイリアスをセットアップして、インデックスに関連付けてください。", "xpack.idxMgmt.templateForm.stepAliases.aliasesEditorHelpText": "JSON フォーマットを使用: {code}", "xpack.idxMgmt.templateForm.stepAliases.docsButtonLabel": "インデックステンプレートドキュメント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e821c4fb228993..cddc9b6d38ad5e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3973,6 +3973,27 @@ "xpack.uiActionsEnhanced.customizeTimeRangeMenuItem.displayName": "定制时间范围", "xpack.uiActionsEnhanced.components.DiscoverDrilldownConfig.chooseIndexPattern": "选择目标索引模式", "xpack.uiActionsEnhanced.drilldown.goToDiscover": "前往 Discover(示例)", + "xpack.uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.helpText": "向下钻取可用于定义与面板交互时的新行为。可以添加多个选项或仅覆盖默认筛选行为。", + "xpack.uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "隐藏", + "xpack.uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "查看文档", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel": "创建向下钻取", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "创建向下钻取", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "删除向下钻取", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel": "保存", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "编辑向下钻取", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "您已删除向下钻取。", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "向下钻取已删除", + "xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "保存向下钻取时出错", + "xpack.uiActionsEnhanced.drilldowns.components.FlyoutFrame.BackButtonLabel": "上一步", + "xpack.uiActionsEnhanced.drilldowns.components.FlyoutFrame.CloseButtonLabel": "关闭", + "xpack.uiActionsEnhanced.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle": "管理向下钻取", + "xpack.uiActionsEnhanced.drilldowns.components.FormCreateDrilldown.drilldownAction": "操作", + "xpack.uiActionsEnhanced.drilldowns.components.FormCreateDrilldown.nameOfDrilldown": "名称", + "xpack.uiActionsEnhanced.drilldowns.components.FormCreateDrilldown.untitledDrilldown": "未命名向下钻取", + "xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel": "新建", + "xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel": "删除 ({count})", + "xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel": "编辑", + "xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel": "选择此向下钻取", "xpack.alerts.alertNavigationRegistry.get.missingNavigationError": "在“{consumer}”内针对告警类型“{alertType}”的导航未注册。", "xpack.alerts.alertNavigationRegistry.register.duplicateDefaultError": "“{consumer}”内的默认导航已注册。", "xpack.alerts.alertNavigationRegistry.register.duplicateNavigationError": "在“{consumer}”内针对告警类型“{alertType}”的导航已注册。", @@ -6125,27 +6146,6 @@ "xpack.data.kueryAutocomplete.orOperatorDescription.oneOrMoreArgumentsText": "一个或多个参数", "xpack.data.query.queryBar.cancelLongQuery": "取消", "xpack.data.query.queryBar.runBeyond": "运行超时", - "xpack.drilldowns.components.DrilldownHelloBar.helpText": "向下钻取可用于定义与面板交互时的新行为。可以添加多个选项或仅覆盖默认筛选行为。", - "xpack.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel": "隐藏", - "xpack.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel": "查看文档", - "xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel": "创建向下钻取", - "xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle": "创建向下钻取", - "xpack.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel": "删除向下钻取", - "xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel": "保存", - "xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle": "编辑向下钻取", - "xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText": "您已删除向下钻取。", - "xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle": "向下钻取已删除", - "xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle": "保存向下钻取时出错", - "xpack.drilldowns.components.FlyoutFrame.BackButtonLabel": "上一步", - "xpack.drilldowns.components.FlyoutFrame.CloseButtonLabel": "关闭", - "xpack.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle": "管理向下钻取", - "xpack.drilldowns.components.FormCreateDrilldown.drilldownAction": "操作", - "xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown": "名称", - "xpack.drilldowns.components.FormCreateDrilldown.untitledDrilldown": "未命名向下钻取", - "xpack.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel": "新建", - "xpack.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel": "删除 ({count})", - "xpack.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel": "编辑", - "xpack.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel": "选择此向下钻取", "xpack.features.advancedSettingsFeatureName": "高级设置", "xpack.features.dashboardFeatureName": "仪表板", "xpack.features.devToolsFeatureName": "开发工具", @@ -7084,12 +7084,9 @@ "xpack.idxMgmt.templateEdit.managedTemplateWarningTitle": "不允许编辑托管模板", "xpack.idxMgmt.templateEdit.systemTemplateWarningDescription": "系统模板对内部操作至关重要。", "xpack.idxMgmt.templateEdit.systemTemplateWarningTitle": "编辑系统模板会使 Kibana 无法运行", - "xpack.idxMgmt.templateForm.backButtonLabel": "上一步", "xpack.idxMgmt.templateForm.createButtonLabel": "创建模板", - "xpack.idxMgmt.templateForm.nextButtonLabel": "下一步", "xpack.idxMgmt.templateForm.saveButtonLabel": "保存模板", "xpack.idxMgmt.templateForm.saveTemplateError": "无法创建模板", - "xpack.idxMgmt.templateForm.savingButtonLabel": "正在保存……", "xpack.idxMgmt.templateForm.stepAliases.aliasesDescription": "设置要与索引关联的别名。", "xpack.idxMgmt.templateForm.stepAliases.aliasesEditorHelpText": "使用 JSON 格式:{code}", "xpack.idxMgmt.templateForm.stepAliases.docsButtonLabel": "索引模板文档", diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx similarity index 62% rename from x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx index 5fde4fc79e4333..cd8452ff74ab4b 100644 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx @@ -12,28 +12,23 @@ import { dashboardFactory, urlFactory, // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../ui_actions_enhanced/public/components/action_wizard/test_data'; -import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; +} from '../../../components/action_wizard/test_data'; +import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; +import { StubBrowserStorage } from '../../../../../../../src/test_utils/public/stub_browser_storage'; import { mockDynamicActionManager } from './test_data'; +import { ActionFactory } from '../../../dynamic_actions'; const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ - uiActionsEnhanced: { - getActionFactories() { - return [dashboardFactory, urlFactory]; + actionFactories: [dashboardFactory as ActionFactory, urlFactory as ActionFactory], + storage: new Storage(new StubBrowserStorage()), + toastService: { + addError: (...args: any[]) => { + alert(JSON.stringify(args)); + }, + addSuccess: (...args: any[]) => { + alert(JSON.stringify(args)); }, } as any, - storage: new Storage(new StubBrowserStorage()), - notifications: { - toasts: { - addError: (...args: any[]) => { - alert(JSON.stringify(args)); - }, - addSuccess: (...args: any[]) => { - alert(JSON.stringify(args)); - }, - } as any, - }, }); storiesOf('components/FlyoutManageDrilldowns', module).add('default', () => ( diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx similarity index 89% rename from x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx index 32cbec795d0924..161caa9782f020 100644 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -8,29 +8,23 @@ import React from 'react'; import { cleanup, fireEvent, render, wait } from '@testing-library/react/pure'; import '@testing-library/jest-dom/extend-expect'; import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; -import { - dashboardFactory, - urlFactory, -} from '../../../../ui_actions_enhanced/public/components/action_wizard/test_data'; -import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; -import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { dashboardFactory, urlFactory } from '../../../components/action_wizard/test_data'; +import { StubBrowserStorage } from '../../../../../../../src/test_utils/public/stub_browser_storage'; +import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; import { mockDynamicActionManager } from './test_data'; import { TEST_SUBJ_DRILLDOWN_ITEM } from '../list_manage_drilldowns'; import { WELCOME_MESSAGE_TEST_SUBJ } from '../drilldown_hello_bar'; -import { coreMock } from '../../../../../../src/core/public/mocks'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; import { NotificationsStart } from 'kibana/public'; import { toastDrilldownsCRUDError } from './i18n'; +import { ActionFactory } from '../../../dynamic_actions'; const storage = new Storage(new StubBrowserStorage()); -const notifications = coreMock.createStart().notifications; +const toasts = coreMock.createStart().notifications.toasts; const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ - uiActionsEnhanced: { - getActionFactories() { - return [dashboardFactory, urlFactory]; - }, - } as any, - storage, - notifications, + actionFactories: [dashboardFactory as ActionFactory, urlFactory as ActionFactory], + storage: new Storage(new StubBrowserStorage()), + toastService: toasts, }); // https://github.com/elastic/kibana/issues/59469 @@ -38,8 +32,8 @@ afterEach(cleanup); beforeEach(() => { storage.clear(); - (notifications.toasts as jest.Mocked).addSuccess.mockClear(); - (notifications.toasts as jest.Mocked).addError.mockClear(); + (toasts as jest.Mocked).addSuccess.mockClear(); + (toasts as jest.Mocked).addError.mockClear(); }); test('Allows to manage drilldowns', async () => { @@ -163,7 +157,7 @@ test('Create only mode', async () => { }); fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - await wait(() => expect(notifications.toasts.addSuccess).toBeCalled()); + await wait(() => expect(toasts.addSuccess).toBeCalled()); expect(onClose).toBeCalled(); expect(await mockDynamicActionManager.state.get().events.length).toBe(1); }); @@ -194,7 +188,7 @@ test('After switching between action factories state is restored', async () => { expect(screen.getByLabelText(/name/i)).toHaveValue('test'); fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - await wait(() => expect(notifications.toasts.addSuccess).toBeCalled()); + await wait(() => expect(toasts.addSuccess).toBeCalled()); expect(await (mockDynamicActionManager.state.get().events[0].action.config as any).url).toBe( 'https://elastic.co' ); @@ -220,7 +214,7 @@ test("Error when can't save drilldown changes", async () => { }); fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); await wait(() => - expect(notifications.toasts.addError).toBeCalledWith(error, { title: toastDrilldownsCRUDError }) + expect(toasts.addError).toBeCalledWith(error, { title: toastDrilldownsCRUDError }) ); }); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx similarity index 86% rename from x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx index e05547741871ef..fbc72d04706351 100644 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -5,24 +5,17 @@ */ import React, { useEffect, useState } from 'react'; +import { ToastsStart } from 'kibana/public'; import useMountedState from 'react-use/lib/useMountedState'; -import { - UiActionsEnhancedActionFactory as ActionFactory, - AdvancedUiActionsStart, - UiActionsEnhancedDynamicActionManager as DynamicActionManager, - UiActionsEnhancedSerializedAction, - UiActionsEnhancedSerializedEvent, -} from '../../../../ui_actions_enhanced/public'; -import { NotificationsStart } from '../../../../../../src/core/public'; import { DrilldownWizardConfig, FlyoutDrilldownWizard } from '../flyout_drilldown_wizard'; import { FlyoutListManageDrilldowns } from '../flyout_list_manage_drilldowns'; -import { IStorageWrapper } from '../../../../../../src/plugins/kibana_utils/public'; +import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; import { VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER, TriggerContextMapping, -} from '../../../../../../src/plugins/ui_actions/public'; -import { useContainerState } from '../../../../../../src/plugins/kibana_utils/public'; +} from '../../../../../../../src/plugins/ui_actions/public'; +import { useContainerState } from '../../../../../../../src/plugins/kibana_utils/public'; import { DrilldownListItem } from '../list_manage_drilldowns'; import { toastDrilldownCreated, @@ -31,6 +24,12 @@ import { toastDrilldownsCRUDError, toastDrilldownsDeleted, } from './i18n'; +import { + ActionFactory, + DynamicActionManager, + SerializedAction, + SerializedEvent, +} from '../../../dynamic_actions'; interface ConnectedFlyoutManageDrilldownsProps { dynamicActionManager: DynamicActionManager; @@ -48,19 +47,16 @@ enum Routes { } export function createFlyoutManageDrilldowns({ - uiActionsEnhanced, + actionFactories: allActionFactories, storage, - notifications, + toastService, docsLink, }: { - uiActionsEnhanced: AdvancedUiActionsStart; + actionFactories: ActionFactory[]; storage: IStorageWrapper; - notifications: NotificationsStart; + toastService: ToastsStart; docsLink?: string; }) { - // fine to assume this is static, - // because all action factories should be registered in setup phase - const allActionFactories = uiActionsEnhanced.getActionFactories(); const allActionFactoriesById = allActionFactories.reduce((acc, next) => { acc[next.id] = next; return acc; @@ -98,7 +94,7 @@ export function createFlyoutManageDrilldowns({ createDrilldown, editDrilldown, deleteDrilldown, - } = useDrilldownsStateManager(props.dynamicActionManager, notifications); + } = useDrilldownsStateManager(props.dynamicActionManager, toastService); /** * isCompatible promise is not yet resolved. @@ -130,9 +126,7 @@ export function createFlyoutManageDrilldowns({ /** * Maps drilldown to list item view model */ - function mapToDrilldownToDrilldownListItem( - drilldown: UiActionsEnhancedSerializedEvent - ): DrilldownListItem { + function mapToDrilldownToDrilldownListItem(drilldown: SerializedEvent): DrilldownListItem { const actionFactory = allActionFactoriesById[drilldown.action.factoryId]; return { id: drilldown.eventId, @@ -260,10 +254,7 @@ function useWelcomeMessage(storage: IStorageWrapper): [boolean, () => void] { ]; } -function useDrilldownsStateManager( - actionManager: DynamicActionManager, - notifications: NotificationsStart -) { +function useDrilldownsStateManager(actionManager: DynamicActionManager, toastService: ToastsStart) { const { events: drilldowns } = useContainerState(actionManager.state); const [isLoading, setIsLoading] = useState(false); const isMounted = useMountedState(); @@ -273,7 +264,7 @@ function useDrilldownsStateManager( try { await op(); } catch (e) { - notifications.toasts.addError(e, { + toastService.addError(e, { title: toastDrilldownsCRUDError, }); if (!isMounted) return; @@ -283,12 +274,12 @@ function useDrilldownsStateManager( } async function createDrilldown( - action: UiActionsEnhancedSerializedAction, + action: SerializedAction, selectedTriggers: Array ) { await run(async () => { await actionManager.createEvent(action, selectedTriggers); - notifications.toasts.addSuccess({ + toastService.addSuccess({ title: toastDrilldownCreated.title(action.name), text: toastDrilldownCreated.text, }); @@ -297,12 +288,12 @@ function useDrilldownsStateManager( async function editDrilldown( drilldownId: string, - action: UiActionsEnhancedSerializedAction, + action: SerializedAction, selectedTriggers: Array ) { await run(async () => { await actionManager.updateEvent(drilldownId, action, selectedTriggers); - notifications.toasts.addSuccess({ + toastService.addSuccess({ title: toastDrilldownEdited.title(action.name), text: toastDrilldownEdited.text, }); @@ -313,7 +304,7 @@ function useDrilldownsStateManager( await run(async () => { drilldownIds = Array.isArray(drilldownIds) ? drilldownIds : [drilldownIds]; await actionManager.deleteEvents(drilldownIds); - notifications.toasts.addSuccess( + toastService.addSuccess( drilldownIds.length === 1 ? { title: toastDrilldownDeleted.title, diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/i18n.ts similarity index 61% rename from x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/i18n.ts index 851439eccbe7ea..e75ee2634aa43c 100644 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/i18n.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const toastDrilldownCreated = { title: (drilldownName: string) => i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', { defaultMessage: 'Drilldown "{drilldownName}" created', values: { @@ -18,7 +18,7 @@ export const toastDrilldownCreated = { } ), text: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', { // TODO: remove `Save your dashboard before testing.` part // when drilldowns are used not only in dashboard @@ -30,14 +30,17 @@ export const toastDrilldownCreated = { export const toastDrilldownEdited = { title: (drilldownName: string) => - i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', { - defaultMessage: 'Drilldown "{drilldownName}" updated', - values: { - drilldownName, - }, - }), + i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', + { + defaultMessage: 'Drilldown "{drilldownName}" updated', + values: { + drilldownName, + }, + } + ), text: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', { defaultMessage: 'Save your dashboard before testing.', } @@ -46,13 +49,13 @@ export const toastDrilldownEdited = { export const toastDrilldownDeleted = { title: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', { defaultMessage: 'Drilldown deleted', } ), text: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', { defaultMessage: 'Save your dashboard before testing.', } @@ -62,14 +65,14 @@ export const toastDrilldownDeleted = { export const toastDrilldownsDeleted = { title: (n: number) => i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', { defaultMessage: '{n} drilldowns deleted', values: { n }, } ), text: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', { defaultMessage: 'Save your dashboard before testing.', } @@ -77,7 +80,7 @@ export const toastDrilldownsDeleted = { }; export const toastDrilldownsCRUDError = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', { defaultMessage: 'Error saving drilldown', description: 'Title for generic error toast when persisting drilldown updates failed', diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/index.ts diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/test_data.ts similarity index 90% rename from x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/test_data.ts index d585fa0692e8c1..58c36e36481b88 100644 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/test_data.ts @@ -9,9 +9,9 @@ import { UiActionsEnhancedDynamicActionManager as DynamicActionManager, UiActionsEnhancedDynamicActionManagerState as DynamicActionManagerState, UiActionsEnhancedSerializedAction, -} from '../../../../ui_actions_enhanced/public'; -import { TriggerContextMapping } from '../../../../../../src/plugins/ui_actions/public'; -import { createStateContainer } from '../../../../../../src/plugins/kibana_utils/common'; +} from '../../../index'; +import { TriggerContextMapping } from '../../../../../../../src/plugins/ui_actions/public'; +import { createStateContainer } from '../../../../../../../src/plugins/kibana_utils/common'; class MockDynamicActionManager implements PublicMethodsOf { public readonly state = createStateContainer({ diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx similarity index 93% rename from x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx index c4a4630397f1c2..df168275fceb3b 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx @@ -6,7 +6,7 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; -import { DrilldownHelloBar } from '.'; +import { DrilldownHelloBar } from './index'; const Demo = () => { const [show, setShow] = React.useState(true); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/drilldown_hello_bar/drilldown_hello_bar.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/drilldown_hello_bar/drilldown_hello_bar.tsx diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/drilldown_hello_bar/i18n.ts similarity index 73% rename from x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/drilldown_hello_bar/i18n.ts index 622376c5b40ad1..e857366690c455 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/drilldown_hello_bar/i18n.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; export const txtHelpText = i18n.translate( - 'xpack.drilldowns.components.DrilldownHelloBar.helpText', + 'xpack.uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.helpText', { defaultMessage: 'Drilldowns enable you to define new behaviors for interacting with panels. You can add multiple actions and override the default filter.', @@ -15,14 +15,14 @@ export const txtHelpText = i18n.translate( ); export const txtViewDocsLinkLabel = i18n.translate( - 'xpack.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel', + 'xpack.uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel', { defaultMessage: 'View docs', } ); export const txtHideHelpButtonLabel = i18n.translate( - 'xpack.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel', + 'xpack.uiActionsEnhanced.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel', { defaultMessage: 'Hide', } diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/index.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/drilldown_hello_bar/index.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/index.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/drilldown_hello_bar/index.tsx diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx similarity index 90% rename from x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx index be048bf920602a..2069a83ab8ba01 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx @@ -9,13 +9,13 @@ import * as React from 'react'; import { EuiFlyout } from '@elastic/eui'; import { storiesOf } from '@storybook/react'; -import { FlyoutDrilldownWizard } from '.'; +import { FlyoutDrilldownWizard } from './index'; import { dashboardFactory, urlFactory, // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../ui_actions_enhanced/public/components/action_wizard/test_data'; -import { UiActionsEnhancedActionFactory as ActionFactory } from '../../../../ui_actions_enhanced/public/'; +} from '../../../components/action_wizard/test_data'; +import { ActionFactory } from '../../../dynamic_actions'; storiesOf('components/FlyoutDrilldownWizard', module) .add('default', () => { diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx similarity index 98% rename from x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx index 8994aac4123e12..58cf2501280c72 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -16,7 +16,7 @@ import { txtEditDrilldownTitle, } from './i18n'; import { DrilldownHelloBar } from '../drilldown_hello_bar'; -import { UiActionsEnhancedActionFactory as ActionFactory } from '../../../../ui_actions_enhanced/public'; +import { ActionFactory } from '../../../dynamic_actions'; export interface DrilldownWizardConfig { name: string; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/i18n.ts similarity index 62% rename from x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/i18n.ts index a4a2754a444ab3..86485dd7b8bbe4 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/i18n.ts @@ -7,35 +7,35 @@ import { i18n } from '@kbn/i18n'; export const txtCreateDrilldownTitle = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle', { defaultMessage: 'Create Drilldown', } ); export const txtEditDrilldownTitle = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle', { defaultMessage: 'Edit Drilldown', } ); export const txtCreateDrilldownButtonLabel = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel', { defaultMessage: 'Create drilldown', } ); export const txtEditDrilldownButtonLabel = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel', { defaultMessage: 'Save', } ); export const txtDeleteDrilldownButtonLabel = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', + 'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', { defaultMessage: 'Delete drilldown', } diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/index.ts diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/flyout_frame.story.tsx similarity index 97% rename from x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/flyout_frame.story.tsx index 7ef0bc5a8bee4d..d47c34c4c7ec90 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/flyout_frame.story.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { EuiFlyout, EuiButton } from '@elastic/eui'; import { storiesOf } from '@storybook/react'; -import { FlyoutFrame } from '.'; +import { FlyoutFrame } from './index'; storiesOf('components/FlyoutFrame', module) .add('default', () => { diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/flyout_frame.test.tsx similarity index 98% rename from x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/flyout_frame.test.tsx index 0a3989487745f9..cdbf36d81de334 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/flyout_frame.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render } from 'react-dom'; import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; -import { FlyoutFrame } from '.'; +import { FlyoutFrame } from './index'; afterEach(cleanup); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/flyout_frame.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/flyout_frame.tsx diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/i18n.ts new file mode 100644 index 00000000000000..cf0cd95e25c795 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/i18n.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtClose = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.FlyoutFrame.CloseButtonLabel', + { + defaultMessage: 'Close', + } +); + +export const txtBack = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.FlyoutFrame.BackButtonLabel', + { + defaultMessage: 'Back', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/index.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/index.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/flyout_frame/index.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_frame/index.tsx diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/i18n.ts similarity index 79% rename from x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/i18n.ts index 0dd4e37d4dddd7..f3aba205c0471b 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/i18n.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; export const txtManageDrilldowns = i18n.translate( - 'xpack.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle', + 'xpack.uiActionsEnhanced.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle', { defaultMessage: 'Manage Drilldowns', } diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/index.ts diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx similarity index 94% rename from x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx index 2fc35eb6b5298f..fe63b0835af9e4 100644 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx @@ -6,7 +6,7 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; -import { FormDrilldownWizard } from '.'; +import { FormDrilldownWizard } from './index'; const DemoEditName: React.FC = () => { const [name, setName] = React.useState(''); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx similarity index 94% rename from x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx index 1813851d728db6..622ed58e3625d7 100644 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -7,10 +7,8 @@ import React from 'react'; import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui'; import { txtDrilldownAction, txtNameOfDrilldown, txtUntitledDrilldown } from './i18n'; -import { - UiActionsEnhancedActionFactory as ActionFactory, - ActionWizard, -} from '../../../../ui_actions_enhanced/public'; +import { ActionFactory } from '../../../dynamic_actions'; +import { ActionWizard } from '../../../components/action_wizard'; const noopFn = () => {}; diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/i18n.ts similarity index 68% rename from x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/i18n.ts index e9b19ab0afa973..9636b6e8a74e7a 100644 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/i18n.ts @@ -7,21 +7,21 @@ import { i18n } from '@kbn/i18n'; export const txtNameOfDrilldown = i18n.translate( - 'xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown', + 'xpack.uiActionsEnhanced.drilldowns.components.FormCreateDrilldown.nameOfDrilldown', { defaultMessage: 'Name', } ); export const txtUntitledDrilldown = i18n.translate( - 'xpack.drilldowns.components.FormCreateDrilldown.untitledDrilldown', + 'xpack.uiActionsEnhanced.drilldowns.components.FormCreateDrilldown.untitledDrilldown', { defaultMessage: 'Untitled drilldown', } ); export const txtDrilldownAction = i18n.translate( - 'xpack.drilldowns.components.FormCreateDrilldown.drilldownAction', + 'xpack.uiActionsEnhanced.drilldowns.components.FormCreateDrilldown.drilldownAction', { defaultMessage: 'Action', } diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/index.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/index.tsx diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/index.ts new file mode 100644 index 00000000000000..a6bed0078bfd77 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/i18n.ts similarity index 54% rename from x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/i18n.ts index fbc7c9dcfb4a18..65087b8523cb16 100644 --- a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/i18n.ts @@ -7,29 +7,32 @@ import { i18n } from '@kbn/i18n'; export const txtCreateDrilldown = i18n.translate( - 'xpack.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel', + 'xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel', { defaultMessage: 'Create new', } ); export const txtEditDrilldown = i18n.translate( - 'xpack.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel', + 'xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel', { defaultMessage: 'Edit', } ); export const txtDeleteDrilldowns = (count: number) => - i18n.translate('xpack.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel', { - defaultMessage: 'Delete ({count})', - values: { - count, - }, - }); + i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel', + { + defaultMessage: 'Delete ({count})', + values: { + count, + }, + } + ); export const txtSelectDrilldown = i18n.translate( - 'xpack.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel', + 'xpack.uiActionsEnhanced.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel', { defaultMessage: 'Select this drilldown', } diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/index.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/index.tsx diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.tsx similarity index 100% rename from x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.tsx diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/index.ts index 7f81a68c803eba..0d469e46fa9fd9 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/index.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/index.ts @@ -5,3 +5,4 @@ */ export * from './drilldown_definition'; +export * from './components'; diff --git a/x-pack/plugins/ui_actions_enhanced/public/mocks.ts b/x-pack/plugins/ui_actions_enhanced/public/mocks.ts index 65fde12755beb8..196b8f2c1d5c79 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/mocks.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/mocks.ts @@ -27,6 +27,7 @@ const createStartContract = (): Start => { ...uiActionsPluginMock.createStartContract(), getActionFactories: jest.fn(), getActionFactory: jest.fn(), + FlyoutManageDrilldowns: jest.fn(), }; return startContract; diff --git a/x-pack/plugins/ui_actions_enhanced/public/plugin.ts b/x-pack/plugins/ui_actions_enhanced/public/plugin.ts index d79996d5ecc1b4..04caef92f15a22 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/plugin.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/plugin.ts @@ -24,7 +24,6 @@ import { CUSTOM_TIME_RANGE, TimeRangeActionContext, } from './custom_time_range_action'; - import { CustomTimeRangeBadge, CUSTOM_TIME_RANGE_BADGE, @@ -32,6 +31,8 @@ import { } from './custom_time_range_badge'; import { CommonlyUsedRange } from './types'; import { UiActionsServiceEnhancements } from './services'; +import { createFlyoutManageDrilldowns } from './drilldowns'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; interface SetupDependencies { embeddable: EmbeddableSetup; // Embeddable are needed because they register basic triggers/actions. @@ -49,7 +50,9 @@ export interface SetupContract export interface StartContract extends UiActionsStart, - Pick {} + Pick { + FlyoutManageDrilldowns: ReturnType; +} declare module '../../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { @@ -94,6 +97,12 @@ export class AdvancedUiActionsPublicPlugin return { ...uiActions, ...this.enhancements, + FlyoutManageDrilldowns: createFlyoutManageDrilldowns({ + actionFactories: this.enhancements.getActionFactories(), + storage: new Storage(window?.localStorage), + toastService: core.notifications.toasts, + docsLink: core.docLinks.links.dashboard.drilldowns, + }), }; } diff --git a/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js b/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js index 2a192fc56469e3..1e3ab0d96b81c8 100644 --- a/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js +++ b/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js @@ -9,5 +9,8 @@ import { join } from 'path'; // eslint-disable-next-line require('@kbn/storybook').runStorybookCli({ name: 'ui_actions_enhanced', - storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], + storyGlobs: [ + join(__dirname, '..', 'public', 'components', '**', '*.story.tsx'), + join(__dirname, '..', 'public', 'drilldowns', 'components', '**', '*.story.tsx'), + ], }); diff --git a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap index dbdfd6b27e69f5..fcf68ad97c8cec 100644 --- a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -104,6 +104,29 @@ Array [
+
+ +
) : null; + const kibana = useKibana(); + const extraLinkComponents = !extraLinks ? null : ( @@ -64,6 +71,15 @@ export const PageHeader = React.memo( + + + {ADD_DATA_LABEL} + + ); diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 37b22a687741ed..6cafa3eeef08e3 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -51,6 +51,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/licensing_plugin/config.legacy.ts'), require.resolve('../test/endpoint_api_integration_no_ingest/config.ts'), require.resolve('../test/reporting_api_integration/config.js'), + require.resolve('../test/functional_embedded/config.ts'), ]; require('@kbn/plugin-helpers').babelRegister(); diff --git a/x-pack/test/accessibility/apps/home.ts b/x-pack/test/accessibility/apps/home.ts index fe698acec322a8..1f05ff676e3a04 100644 --- a/x-pack/test/accessibility/apps/home.ts +++ b/x-pack/test/accessibility/apps/home.ts @@ -12,8 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const globalNav = getService('globalNav'); - // FLAKY: https://github.com/elastic/kibana/issues/66976 - describe.skip('Kibana Home', () => { + describe('Kibana Home', () => { before(async () => { await PageObjects.common.navigateToApp('home'); }); diff --git a/x-pack/test/accessibility/apps/search_profiler.ts b/x-pack/test/accessibility/apps/search_profiler.ts index 138231d3cf025d..8a13940695f9e1 100644 --- a/x-pack/test/accessibility/apps/search_profiler.ts +++ b/x-pack/test/accessibility/apps/search_profiler.ts @@ -14,8 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const a11y = getService('a11y'); const flyout = getService('flyout'); - // FLAKY: https://github.com/elastic/kibana/issues/67821 - describe.skip('Accessibility Search Profiler Editor', () => { + describe('Accessibility Search Profiler Editor', () => { before(async () => { await PageObjects.common.navigateToApp('searchProfiler'); await a11y.testAppSnapshot(); diff --git a/x-pack/test/api_integration/apis/endpoint/alerts/index.ts b/x-pack/test/api_integration/apis/endpoint/alerts/index.ts index 12c5857f9db398..ed287834761d8b 100644 --- a/x-pack/test/api_integration/apis/endpoint/alerts/index.ts +++ b/x-pack/test/api_integration/apis/endpoint/alerts/index.ts @@ -7,12 +7,19 @@ import expect from '@kbn/expect/expect.js'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { AlertData } from '../../../../../plugins/security_solution/common/endpoint_alerts/types'; import { eventsIndexPattern } from '../../../../../plugins/security_solution/common/endpoint/constants'; -import { deleteEventsStream, deleteMetadataStream } from '../data_stream_helper'; +import { + deleteEventsStream, + deleteMetadataStream, + deletePolicyStream, +} from '../data_stream_helper'; +import { indexHostsAndAlerts } from '../../../../../plugins/security_solution/common/endpoint/index_data'; /** * The number of alert documents in the es archive. */ -const numberOfAlertsInFixture = 12; +const numberOfHosts = 3; +const numberOfAlertsPerHost = 4; +const numberOfAlertsInFixture = numberOfHosts * numberOfAlertsPerHost; /** * The default number of entries returned when no page_size is specified. @@ -57,10 +64,9 @@ const ES_QUERY_MISSING = { }; export default function ({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); const es = getService('legacyEs'); - + const client = getService('es'); const nextPrevPrefixQuery = "query=(language:kuery,query:'')"; const nextPrevPrefixDateRange = "date_range=(from:'2018-01-10T00:00:00.000Z',to:now)"; const nextPrevPrefixSort = 'sort=@timestamp'; @@ -73,8 +79,17 @@ export default function ({ getService }: FtrProviderContext) { describe('Endpoint alert API', () => { describe('when data is in elasticsearch', () => { before(async () => { - await esArchiver.load('endpoint/alerts/api_feature', { useCreate: true }); - await esArchiver.load('endpoint/alerts/host_api_feature', { useCreate: true }); + await indexHostsAndAlerts( + client, + 'alerts-seed', + numberOfHosts, + 1, + 'metrics-endpoint.metadata-default-1', + 'metrics-endpoint.policy-default-1', + 'events-endpoint-1', + numberOfAlertsPerHost + ); + const res = await es.search({ index: eventsIndexPattern, body: ES_QUERY_MISSING, @@ -85,7 +100,11 @@ export default function ({ getService }: FtrProviderContext) { after(async () => { // the endpoint uses data streams and es archiver does not support deleting them at the moment so we need // to do it manually - await Promise.all([deleteEventsStream(getService), deleteMetadataStream(getService)]); + await Promise.all([ + deleteEventsStream(getService), + deleteMetadataStream(getService), + deletePolicyStream(getService), + ]); }); it('should not support POST requests', async () => { @@ -159,7 +178,7 @@ export default function ({ getService }: FtrProviderContext) { expect(body.message).to.contain('Value must be equal to or greater than [1]'); }); - it('should return links to the next and previous pages using cursor-based pagination', async () => { + it('should return working link to the next page using cursor-based pagination', async () => { const { body } = await supertest .get('/api/endpoint/alerts?page_index=0') .set('kbn-xsrf', 'xxx') @@ -170,51 +189,75 @@ export default function ({ getService }: FtrProviderContext) { expect(body.next).to.eql( `/api/endpoint/alerts?${nextPrevPrefix}&after=${lastTimestampFirstPage}&after=${lastEventIdFirstPage}` ); - }); - it('should return data using `next` link', async () => { - const { body } = await supertest - .get( - `/api/endpoint/alerts?${nextPrevPrefix}&after=1584044338719&after=66008e21-2493-4b15-a937-939ea228064a` - ) + const { body: nextBody } = await supertest + .get(body.next) .set('kbn-xsrf', 'xxx') .expect(200); - expect(body.alerts.length).to.eql(defaultPageSize); - const firstTimestampNextPage = body.alerts[0]['@timestamp']; - const firstEventIdNextPage = body.alerts[0].event.id; - expect(body.prev).to.eql( + expect(nextBody.alerts.length).to.eql(2); + const firstTimestampNextPage = nextBody.alerts[0]['@timestamp']; + const firstEventIdNextPage = nextBody.alerts[0].event.id; + expect(nextBody.prev).to.eql( `/api/endpoint/alerts?${nextPrevPrefix}&before=${firstTimestampNextPage}&before=${firstEventIdNextPage}` ); }); - it('should return data using `prev` link', async () => { + it('should return working link to the prev page using cursor-based pagination', async () => { const { body } = await supertest - .get( - `/api/endpoint/alerts?${nextPrevPrefix}&before=1542789412000&before=823d814d-fa0c-4e53-a94c-f6b296bb965b` - ) + .get('/api/endpoint/alerts?page_index=1') .set('kbn-xsrf', 'xxx') .expect(200); - expect(body.alerts.length).to.eql(10); + expect(body.alerts.length).to.eql(2); + const firstTimestamp = body.alerts[0]['@timestamp']; + const firstEventId = body.alerts[0].event.id; + expect(body.prev).to.eql( + `/api/endpoint/alerts?${nextPrevPrefix}&before=${firstTimestamp}&before=${firstEventId}` + ); + + const { body: prevBody } = await supertest + .get(body.prev) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(prevBody.alerts.length).to.eql(10); + const lastTimestampFirstPage = prevBody.alerts[9]['@timestamp']; + const lastEventIdFirstPage = prevBody.alerts[9].event.id; + expect(prevBody.next).to.eql( + `/api/endpoint/alerts?${nextPrevPrefix}&after=${lastTimestampFirstPage}&after=${lastEventIdFirstPage}` + ); }); it('should return no results when `before` is requested past beginning of first page', async () => { const { body } = await supertest + .get('/api/endpoint/alerts') + .set('kbn-xsrf', 'xxx') + .expect(200); + + const { body: emptyBody } = await supertest .get( - `/api/endpoint/alerts?${nextPrevPrefix}&before=1584044338726&before=5ff1a4ec-758e-49e7-89aa-2c6821fe6b54` + `/api/endpoint/alerts?${nextPrevPrefix}&before=${ + body.alerts[0]['@timestamp'] + 1 + }&before=5ff1a4ec-758e-49e7-89aa-2c6821fe6b54` ) .set('kbn-xsrf', 'xxx') .expect(200); - expect(body.alerts.length).to.eql(0); + expect(emptyBody.alerts.length).to.eql(0); }); it('should return no results when `after` is requested past end of last page, descending', async () => { const { body } = await supertest + .get('/api/endpoint/alerts?page_index=1') + .set('kbn-xsrf', 'xxx') + .expect(200); + + const { body: emptyBody } = await supertest .get( - `/api/endpoint/alerts?${nextPrevPrefix}&after=1584044338612&after=6d75d498-3cca-45ad-a304-525b95ae0412` + `/api/endpoint/alerts?${nextPrevPrefix}&after=${ + body.alerts[1]['@timestamp'] - 1 + }&after=6d75d498-3cca-45ad-a304-525b95ae0412` ) .set('kbn-xsrf', 'xxx') .expect(200); - expect(body.alerts.length).to.eql(0); + expect(emptyBody.alerts.length).to.eql(0); }); it('alerts api should return data using `before` by custom sort parameter, descending', async () => { @@ -346,7 +389,12 @@ export default function ({ getService }: FtrProviderContext) { }); it('should filter results of alert data using rison-encoded filters', async () => { - const hostname = 'Host-abmfhmc5ku'; + const { body: firstBody } = await supertest + .get('/api/endpoint/alerts?page_index=0') + .set('kbn-xsrf', 'xxx') + .expect(200); + + const hostname = firstBody.alerts[0].host.hostname; const { body } = await supertest .get( `/api/endpoint/alerts?filters=!((%27%24state%27%3A(store%3AappState)%2Cmeta%3A(alias%3A!n%2Cdisabled%3A!f%2Ckey%3Ahost.hostname%2Cnegate%3A!f%2Cparams%3A(query%3A${hostname})%2Ctype%3Aphrase)%2Cquery%3A(match_phrase%3A(host.hostname%3A${hostname}))))` @@ -361,7 +409,12 @@ export default function ({ getService }: FtrProviderContext) { }); it('should filter results of alert data using KQL', async () => { - const agentID = '7cf9f7a3-28a6-4d1e-bb45-005aa28f18d0'; + const { body: firstBody } = await supertest + .get('/api/endpoint/alerts?page_index=0') + .set('kbn-xsrf', 'xxx') + .expect(200); + + const agentID = firstBody.alerts[0].agent.id; const { body } = await supertest .get( `/api/endpoint/alerts?query=(language%3Akuery%2Cquery%3A%27agent.id%20%3A%20"${agentID}"%27)` diff --git a/x-pack/test/api_integration/apis/endpoint/metadata.ts b/x-pack/test/api_integration/apis/endpoint/metadata.ts index 61f294cbd6f9c8..41531269ddeb95 100644 --- a/x-pack/test/api_integration/apis/endpoint/metadata.ts +++ b/x-pack/test/api_integration/apis/endpoint/metadata.ts @@ -160,18 +160,18 @@ export default function ({ getService }: FtrProviderContext) { expect(body.request_page_index).to.eql(0); }); - it('metadata api should return page based on host.os.variant filter.', async () => { + it('metadata api should return page based on host.os.Ext.variant filter.', async () => { const variantValue = 'Windows Pro'; const { body } = await supertest .post('/api/endpoint/metadata') .set('kbn-xsrf', 'xxx') .send({ - filter: `host.os.variant:${variantValue}`, + filter: `host.os.Ext.variant:${variantValue}`, }) .expect(200); expect(body.total).to.eql(2); const resultOsVariantValue: Set = new Set( - body.hosts.map((hostInfo: Record) => hostInfo.metadata.host.os.variant) + body.hosts.map((hostInfo: Record) => hostInfo.metadata.host.os.Ext.variant) ); expect(Array.from(resultOsVariantValue)).to.eql([variantValue]); expect(body.hosts.length).to.eql(2); @@ -204,15 +204,14 @@ export default function ({ getService }: FtrProviderContext) { .post('/api/endpoint/metadata') .set('kbn-xsrf', 'xxx') .send({ - filter: `not endpoint.policy.applied.status:success`, + filter: `not Endpoint.policy.applied.status:success`, }) .expect(200); const statuses: Set = new Set( body.hosts.map( - (hostInfo: Record) => hostInfo.metadata.endpoint.policy.applied.status + (hostInfo: Record) => hostInfo.metadata.Endpoint.policy.applied.status ) ); - expect(statuses.size).to.eql(1); expect(Array.from(statuses)).to.eql(['failure']); }); diff --git a/x-pack/test/api_integration/apis/endpoint/policy.ts b/x-pack/test/api_integration/apis/endpoint/policy.ts index 711762cc20abbd..e33423d172567b 100644 --- a/x-pack/test/api_integration/apis/endpoint/policy.ts +++ b/x-pack/test/api_integration/apis/endpoint/policy.ts @@ -27,7 +27,7 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); expect(body.policy_response.host.id).to.eql(expectedHostId); - expect(body.policy_response.endpoint.policy).to.not.be(undefined); + expect(body.policy_response.Endpoint.policy).to.not.be(undefined); }); it('should return not found if host has no policy response', async () => { diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index 3569d044b5fed2..a9ecaac09db9a3 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -41,7 +41,7 @@ export default function ({ getService }) { type: 'index-pattern', }, ]); - expect(resp.body.migrationVersion).to.eql({ map: '7.8.0' }); + expect(resp.body.migrationVersion).to.eql({ map: '7.9.0' }); expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true); }); }); diff --git a/x-pack/test/api_integration/services/ingest_manager.ts b/x-pack/test/api_integration/services/ingest_manager.ts index bcba36cef42b70..96b1b97a68dc93 100644 --- a/x-pack/test/api_integration/services/ingest_manager.ts +++ b/x-pack/test/api_integration/services/ingest_manager.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { FtrProviderContext } from '../ftr_provider_context'; -import { setupRouteService, fleetSetupRouteService } from '../../../plugins/ingest_manager/common'; +import { fleetSetupRouteService } from '../../../plugins/ingest_manager/common'; export function IngestManagerProvider({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -12,14 +12,11 @@ export function IngestManagerProvider({ getService }: FtrProviderContext) { async setup() { const headers = { accept: 'application/json', 'kbn-xsrf': 'some-xsrf-token' }; - const { body } = await supertest - .get(fleetSetupRouteService.getFleetSetupPath()) + await supertest + .post(fleetSetupRouteService.postFleetSetupPath()) .set(headers) + .send({ forceRecreate: true }) .expect(200); - - if (!body.isInitialized) { - await supertest.post(setupRouteService.getSetupPath()).set(headers).expect(200); - } }, }; } diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts index 56d00a4e11390a..c23abead458f17 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts @@ -61,6 +61,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('ml/farequote'); await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.createMLTestDashboardIfNeeded(); await ml.testResources.setKibanaTimeZoneToUTC(); await ml.securityUI.loginAsMlPowerUser(); @@ -125,6 +126,12 @@ export default function ({ getService }: FtrProviderContext) { it('anomalies table is not empty', async () => { await ml.anomaliesTable.assertTableNotEmpty(); }); + + // should be the last step because it navigates away from the Anomaly Explorer page + it('should allow to attach anomaly swimlane embeddable to the dashboard', async () => { + await ml.anomalyExplorer.openAddToDashboardControl(); + await ml.anomalyExplorer.addAndEditSwimlaneInDashboard('ML Test'); + }); }); } }); diff --git a/x-pack/test/functional/apps/ml/index.ts b/x-pack/test/functional/apps/ml/index.ts index 92e836e0c4c1bf..2d8aac3b8dddf8 100644 --- a/x-pack/test/functional/apps/ml/index.ts +++ b/x-pack/test/functional/apps/ml/index.ts @@ -22,6 +22,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlRoles(); await ml.testResources.deleteSavedSearches(); + await ml.testResources.deleteDashboards(); await ml.testResources.deleteIndexPatternByTitle('ft_farequote'); await ml.testResources.deleteIndexPatternByTitle('ft_ecommerce'); diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json b/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json index a8d868ebbec15f..1434aae60c9674 100644 --- a/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json +++ b/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json @@ -15,7 +15,7 @@ "id": "11488bae-880b-4e7b-8d28-aac2aa9de816" } }, - "endpoint": { + "Endpoint": { "policy": { "applied": { "name": "Default", @@ -44,7 +44,9 @@ "full": "Windows 10", "name": "windows 10.0", "version": "10.0", - "variant" : "Windows Pro" + "Ext": { + "variant" : "Windows Pro" + } } } } @@ -68,7 +70,7 @@ "id": "92ac1ce0-e1f7-409e-8af6-f17e97b1fc71" } }, - "endpoint": { + "Endpoint": { "policy": { "applied": { "name": "Default", @@ -96,7 +98,9 @@ "full": "Windows Server 2016", "name": "windows 10.0", "version": "10.0", - "variant" : "Windows Server" + "Ext": { + "variant" : "Windows Server" + } } } } @@ -120,7 +124,7 @@ "id": "023fa40c-411d-4188-a941-4147bfadd095" } }, - "endpoint": { + "Endpoint": { "policy": { "applied": { "name": "Default", @@ -146,7 +150,9 @@ "full": "Windows 10", "name": "windows 10.0", "version": "10.0", - "variant" : "Windows Pro" + "Ext": { + "variant" : "Windows Pro" + } } } } @@ -170,7 +176,7 @@ "id": "11488bae-880b-4e7b-8d28-aac2aa9de816" } }, - "endpoint": { + "Endpoint": { "policy": { "applied": { "name": "Default", @@ -199,7 +205,9 @@ "full": "Windows Server 2016", "name": "windows 10.0", "version": "10.0", - "variant" : "Windows Server 2016" + "Ext": { + "variant" : "Windows Server 2016" + } } } } @@ -223,7 +231,7 @@ "id": "92ac1ce0-e1f7-409e-8af6-f17e97b1fc71" } }, - "endpoint": { + "Endpoint": { "policy": { "applied": { "name": "Default", @@ -250,7 +258,9 @@ "full": "Windows Server 2012", "name": "windows 6.2", "version": "6.2", - "variant" : "Windows Server 2012" + "Ext": { + "variant" : "Windows Server 2012" + } } } } @@ -274,7 +284,7 @@ "id": "023fa40c-411d-4188-a941-4147bfadd095" } }, - "endpoint": { + "Endpoint": { "policy": { "applied": { "name": "With Eventing", @@ -301,7 +311,9 @@ "full": "Windows Server 2012", "name": "windows 6.2", "version": "6.2", - "variant" : "Windows Server 2012" + "Ext": { + "variant" : "Windows Server 2012" + } } } } @@ -325,7 +337,7 @@ "id": "11488bae-880b-4e7b-8d28-aac2aa9de816" } }, - "endpoint": { + "Endpoint": { "policy": { "applied": { "name": "With Eventing", @@ -353,7 +365,9 @@ "full": "Windows Server 2012R2", "name": "windows 6.3", "version": "6.3", - "variant" : "Windows Server 2012 R2" + "Ext": { + "variant" : "Windows Server 2012 R2" + } } } } @@ -377,7 +391,7 @@ "id": "92ac1ce0-e1f7-409e-8af6-f17e97b1fc71" } }, - "endpoint": { + "Endpoint": { "policy": { "applied": { "name": "Default", @@ -404,7 +418,9 @@ "full": "Windows Server 2012R2", "name": "windows 6.3", "version": "6.3", - "variant" : "Windows Server 2012 R2" + "Ext": { + "variant" : "Windows Server 2012 R2" + } } } } @@ -428,7 +444,7 @@ "id": "023fa40c-411d-4188-a941-4147bfadd095" } }, - "endpoint": { + "Endpoint": { "policy": { "applied": { "name": "With Eventing", @@ -455,7 +471,9 @@ "full": "Windows Server 2012", "name": "windows 6.2", "version": "6.2", - "variant" : "Windows Server 2012" + "Ext": { + "variant" : "Windows Server 2012" + } } } } diff --git a/x-pack/test/functional/es_archives/endpoint/policy/data.json.gz b/x-pack/test/functional/es_archives/endpoint/policy/data.json.gz index f380785f021bbf..d9fcf03f43f37d 100644 Binary files a/x-pack/test/functional/es_archives/endpoint/policy/data.json.gz and b/x-pack/test/functional/es_archives/endpoint/policy/data.json.gz differ diff --git a/x-pack/test/functional/services/ml/anomaly_explorer.ts b/x-pack/test/functional/services/ml/anomaly_explorer.ts index 6ec72c76bb9cf0..7c479a4234673e 100644 --- a/x-pack/test/functional/services/ml/anomaly_explorer.ts +++ b/x-pack/test/functional/services/ml/anomaly_explorer.ts @@ -66,5 +66,38 @@ export function MachineLearningAnomalyExplorerProvider({ getService }: FtrProvid async assertSwimlaneViewByExists() { await testSubjects.existOrFail('mlAnomalyExplorerSwimlaneViewBy'); }, + + async openAddToDashboardControl() { + await testSubjects.click('mlAnomalyTimelinePanelMenu'); + await testSubjects.click('mlAnomalyTimelinePanelAddToDashboardButton'); + await testSubjects.existOrFail('mlAddToDashboardModal'); + }, + + async addAndEditSwimlaneInDashboard(dashboardTitle: string) { + await this.filterWithSearchString(dashboardTitle); + await testSubjects.isDisplayed('mlDashboardSelectionTable > checkboxSelectAll'); + await testSubjects.click('mlDashboardSelectionTable > checkboxSelectAll'); + expect(await testSubjects.isChecked('mlDashboardSelectionTable > checkboxSelectAll')).to.be( + true + ); + await testSubjects.clickWhenNotDisabled('mlAddAndEditDashboardButton'); + const embeddable = await testSubjects.find('mlAnomalySwimlaneEmbeddableWrapper'); + const swimlane = await embeddable.findByClassName('ml-swimlanes'); + expect(await swimlane.isDisplayed()).to.eql( + true, + 'Anomaly swimlane should be displayed in dashboard' + ); + }, + + async waitForDashboardsToLoad() { + await testSubjects.existOrFail('~mlDashboardSelectionTable', { timeout: 60 * 1000 }); + }, + + async filterWithSearchString(filter: string) { + await this.waitForDashboardsToLoad(); + const searchBarInput = await testSubjects.find('mlDashboardsSearchBox'); + await searchBarInput.clearValueWithKeyboard(); + await searchBarInput.type(filter); + }, }; } diff --git a/x-pack/test/functional/services/ml/test_resources.ts b/x-pack/test/functional/services/ml/test_resources.ts index 4e3d1d9d86271a..9927c987bbea5d 100644 --- a/x-pack/test/functional/services/ml/test_resources.ts +++ b/x-pack/test/functional/services/ml/test_resources.ts @@ -5,7 +5,7 @@ */ import { ProvidedType } from '@kbn/test/types/ftr'; -import { savedSearches } from './test_resources_data'; +import { savedSearches, dashboards } from './test_resources_data'; import { COMMON_REQUEST_HEADERS } from './common'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -137,6 +137,20 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider return createResponse.id; }, + async createDashboard(title: string, body: object): Promise { + log.debug(`Creating dashboard with title '${title}'`); + + const createResponse = await supertest + .post(`/api/saved_objects/${SavedObjectType.DASHBOARD}`) + .set(COMMON_REQUEST_HEADERS) + .send(body) + .expect(200) + .then((res: any) => res.body); + + log.debug(` > Created with id '${createResponse.id}'`); + return createResponse.id; + }, + async createSavedSearchIfNeeded(savedSearch: any): Promise { const title = savedSearch.requestBody.attributes.title; const savedSearchId = await this.getSavedSearchId(title); @@ -181,6 +195,21 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider await this.createSavedSearchIfNeeded(savedSearches.farequoteFilter); }, + async createMLTestDashboardIfNeeded() { + await this.createDashboardIfNeeded(dashboards.mlTestDashboard); + }, + + async createDashboardIfNeeded(dashboard: any) { + const title = dashboard.requestBody.attributes.title; + const dashboardId = await this.getDashboardId(title); + if (dashboardId !== undefined) { + log.debug(`Dashboard with title '${title}' already exists. Nothing to create.`); + return dashboardId; + } else { + return await this.createDashboard(title, dashboard.requestBody); + } + }, + async createSavedSearchFarequoteLuceneIfNeeded() { await this.createSavedSearchIfNeeded(savedSearches.farequoteLucene); }, @@ -285,6 +314,12 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider } }, + async deleteDashboards() { + for (const dashboard of Object.values(dashboards)) { + await this.deleteDashboardByTitle(dashboard.requestBody.attributes.title); + } + }, + async assertSavedObjectExistsByTitle(title: string, objectType: SavedObjectType) { await retry.waitForWithTimeout( `${objectType} with title '${title}' to exist`, diff --git a/x-pack/test/functional/services/ml/test_resources_data.ts b/x-pack/test/functional/services/ml/test_resources_data.ts index dd600077182f93..2ab1f4de542284 100644 --- a/x-pack/test/functional/services/ml/test_resources_data.ts +++ b/x-pack/test/functional/services/ml/test_resources_data.ts @@ -247,3 +247,22 @@ export const savedSearches = { }, }, }; + +export const dashboards = { + mlTestDashboard: { + requestBody: { + attributes: { + title: 'ML Test', + hits: 0, + description: '', + panelsJSON: '[]', + optionsJSON: '{"hidePanelTitles":false,"useMargins":true}', + version: 1, + timeRestore: false, + kibanaSavedObjectMeta: { + searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}', + }, + }, + }, + }, +}; diff --git a/x-pack/test/functional_embedded/config.firefox.ts b/x-pack/test/functional_embedded/config.firefox.ts new file mode 100644 index 00000000000000..2051d1afd4ab3a --- /dev/null +++ b/x-pack/test/functional_embedded/config.firefox.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const chromeConfig = await readConfigFile(require.resolve('./config')); + + return { + ...chromeConfig.getAll(), + + browser: { + type: 'firefox', + acceptInsecureCerts: true, + }, + + suiteTags: { + exclude: ['skipFirefox'], + }, + + junit: { + reportName: 'Firefox Kibana Embedded in iframe with X-Pack Security', + }, + }; +} diff --git a/x-pack/test/functional_embedded/config.ts b/x-pack/test/functional_embedded/config.ts new file mode 100644 index 00000000000000..95b290ece7db24 --- /dev/null +++ b/x-pack/test/functional_embedded/config.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Fs from 'fs'; +import { resolve } from 'path'; +import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { pageObjects } from '../functional/page_objects'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaFunctionalConfig = await readConfigFile(require.resolve('../functional/config.js')); + + const iframeEmbeddedPlugin = resolve(__dirname, './plugins/iframe_embedded'); + + const servers = { + ...kibanaFunctionalConfig.get('servers'), + elasticsearch: { + ...kibanaFunctionalConfig.get('servers.elasticsearch'), + }, + kibana: { + ...kibanaFunctionalConfig.get('servers.kibana'), + protocol: 'https', + ssl: { + enabled: true, + key: Fs.readFileSync(KBN_KEY_PATH).toString('utf8'), + certificate: Fs.readFileSync(KBN_CERT_PATH).toString('utf8'), + certificateAuthorities: Fs.readFileSync(CA_CERT_PATH).toString('utf8'), + }, + }, + }; + + return { + testFiles: [require.resolve('./tests')], + servers, + services: kibanaFunctionalConfig.get('services'), + pageObjects, + browser: { + acceptInsecureCerts: true, + }, + junit: { + reportName: 'Kibana Embedded in iframe with X-Pack Security', + }, + + esTestCluster: kibanaFunctionalConfig.get('esTestCluster'), + apps: { + ...kibanaFunctionalConfig.get('apps'), + }, + + kbnTestServer: { + ...kibanaFunctionalConfig.get('kbnTestServer'), + serverArgs: [ + ...kibanaFunctionalConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${iframeEmbeddedPlugin}`, + '--server.ssl.enabled=true', + `--server.ssl.key=${KBN_KEY_PATH}`, + `--server.ssl.certificate=${KBN_CERT_PATH}`, + `--server.ssl.certificateAuthorities=${CA_CERT_PATH}`, + + '--xpack.security.sameSiteCookies=None', + '--xpack.security.secureCookies=true', + ], + }, + }; +} diff --git a/x-pack/test/functional_embedded/ftr_provider_context.d.ts b/x-pack/test/functional_embedded/ftr_provider_context.d.ts new file mode 100644 index 00000000000000..5646c06a3cd309 --- /dev/null +++ b/x-pack/test/functional_embedded/ftr_provider_context.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; +import { pageObjects } from '../functional/page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; +export { pageObjects }; diff --git a/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json b/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json new file mode 100644 index 00000000000000..ea9f55bd21c6ea --- /dev/null +++ b/x-pack/test/functional_embedded/plugins/iframe_embedded/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "iframe_embedded", + "version": "1.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": false +} diff --git a/x-pack/test/functional_embedded/plugins/iframe_embedded/package.json b/x-pack/test/functional_embedded/plugins/iframe_embedded/package.json new file mode 100644 index 00000000000000..9fa1554e5312b5 --- /dev/null +++ b/x-pack/test/functional_embedded/plugins/iframe_embedded/package.json @@ -0,0 +1,14 @@ +{ + "name": "iframe_embedded", + "version": "0.0.0", + "kibana": { + "version": "kibana" + }, + "scripts": { + "kbn": "node ../../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.9.5" + } +} diff --git a/x-pack/test/functional_embedded/plugins/iframe_embedded/server/index.ts b/x-pack/test/functional_embedded/plugins/iframe_embedded/server/index.ts new file mode 100644 index 00000000000000..976ef19d4d8a75 --- /dev/null +++ b/x-pack/test/functional_embedded/plugins/iframe_embedded/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'kibana/server'; +import { IframeEmbeddedPlugin } from './plugin'; + +export const plugin = (initContext: PluginInitializerContext) => + new IframeEmbeddedPlugin(initContext); diff --git a/x-pack/test/functional_embedded/plugins/iframe_embedded/server/plugin.ts b/x-pack/test/functional_embedded/plugins/iframe_embedded/server/plugin.ts new file mode 100644 index 00000000000000..890fe14cf03cfa --- /dev/null +++ b/x-pack/test/functional_embedded/plugins/iframe_embedded/server/plugin.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Url from 'url'; +import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/server'; + +function renderBody(iframeUrl: string) { + return ` + + + + + Kibana embedded in iframe + + +