From 56041f03ada9730e701508dbc10032b69de9f218 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Wed, 8 Jan 2020 12:01:47 +0300 Subject: [PATCH 01/63] Don't expose Elasticsearch client as Observable (#53824) * expose ES clients without observables * expose observable-less api to plugins * update core api and mocks * update plugins * NP SO & legacy use updated API * update SO tests * update TSDocs * update types * update docs * document createCluster analog in np * typo --- ...a-plugin-public.savedobjectsclient.find.md | 2 +- ...kibana-plugin-public.savedobjectsclient.md | 2 +- ...na-plugin-server.clusterclient.asscoped.md | 4 +- .../kibana-plugin-server.clusterclient.md | 2 +- ...r.elasticsearchservicesetup.adminclient.md | 22 +++++++ ....elasticsearchservicesetup.adminclient_.md | 19 ------ ....elasticsearchservicesetup.createclient.md | 2 +- ...er.elasticsearchservicesetup.dataclient.md | 22 +++++++ ...r.elasticsearchservicesetup.dataclient_.md | 19 ------ ...plugin-server.elasticsearchservicesetup.md | 16 +---- .../kibana-plugin-server.iclusterclient.md | 4 +- ...bana-plugin-server.icustomclusterclient.md | 15 +++++ .../core/server/kibana-plugin-server.md | 6 +- .../kibana-plugin-server.scopeablerequest.md | 15 +++++ src/core/MIGRATION.md | 1 + .../server/elasticsearch/cluster_client.ts | 30 +++++++-- .../elasticsearch_service.mock.ts | 60 +++++++++++++---- .../elasticsearch_service.test.ts | 22 +++++++ .../elasticsearch/elasticsearch_service.ts | 65 +++++++++++++++++-- src/core/server/elasticsearch/index.ts | 8 ++- src/core/server/elasticsearch/types.ts | 27 ++++---- src/core/server/index.ts | 2 + src/core/server/legacy/legacy_service.ts | 4 +- src/core/server/mocks.ts | 4 +- src/core/server/plugins/plugin_context.ts | 4 +- .../saved_objects_service.test.ts | 9 ++- .../saved_objects/saved_objects_service.ts | 8 +-- .../service/lib/scoped_client_provider.ts | 6 -- src/core/server/server.api.md | 16 +++-- src/core/server/server.ts | 3 +- .../core_plugins/elasticsearch/index.js | 23 ++----- .../saved_objects/saved_objects_mixin.test.js | 7 +- src/plugins/testbed/server/index.ts | 6 +- .../legacy/plugins/actions/server/plugin.ts | 2 +- .../legacy/plugins/alerting/server/plugin.ts | 4 +- .../plugins/watcher/server/np_ready/plugin.ts | 3 +- x-pack/plugins/apm/server/plugin.ts | 18 +++-- .../plugins/licensing/server/plugin.test.ts | 21 +++--- x-pack/plugins/licensing/server/plugin.ts | 2 +- x-pack/plugins/security/server/plugin.test.ts | 8 +-- x-pack/plugins/security/server/plugin.ts | 4 +- .../spaces/server/lib/create_default_space.ts | 2 +- .../on_post_auth_interceptor.test.ts | 2 +- .../spaces_tutorial_context_factory.test.ts | 2 +- x-pack/plugins/spaces/server/plugin.ts | 6 +- .../routes/api/external/copy_to_space.test.ts | 2 +- .../server/routes/api/external/delete.test.ts | 2 +- .../server/routes/api/external/get.test.ts | 2 +- .../routes/api/external/get_all.test.ts | 2 +- .../server/routes/api/external/post.test.ts | 2 +- .../server/routes/api/external/put.test.ts | 2 +- .../api/internal/get_active_space.test.ts | 2 +- .../spaces_service/spaces_service.test.ts | 2 +- .../server/spaces_service/spaces_service.ts | 10 +-- 54 files changed, 353 insertions(+), 202 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.adminclient.md delete mode 100644 docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.adminclient_.md create mode 100644 docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.dataclient.md delete mode 100644 docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.dataclient_.md create mode 100644 docs/development/core/server/kibana-plugin-server.icustomclusterclient.md create mode 100644 docs/development/core/server/kibana-plugin-server.scopeablerequest.md diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md index 1ce18834f5319..a4fa3f17d0d94 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md @@ -9,5 +9,5 @@ Search for objects Signature: ```typescript -find: (options: Pick) => Promise>; +find: (options: Pick) => Promise>; ``` diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md index 3b916db972673..88485aa71f7c5 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md @@ -24,7 +24,7 @@ The constructor for this class is marked as internal. Third-party code should no | [bulkGet](./kibana-plugin-public.savedobjectsclient.bulkget.md) | | (objects?: {
id: string;
type: string;
}[]) => Promise<SavedObjectsBatchResponse<SavedObjectAttributes>> | Returns an array of objects by id | | [create](./kibana-plugin-public.savedobjectsclient.create.md) | | <T extends SavedObjectAttributes>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>> | Persists an object | | [delete](./kibana-plugin-public.savedobjectsclient.delete.md) | | (type: string, id: string) => Promise<{}> | Deletes an object | -| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "filter" | "type" | "page" | "fields" | "searchFields" | "defaultSearchOperator" | "hasReference" | "sortField" | "perPage">) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | +| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "filter" | "type" | "page" | "perPage" | "sortField" | "fields" | "searchFields" | "hasReference" | "defaultSearchOperator">) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | | [get](./kibana-plugin-public.savedobjectsclient.get.md) | | <T extends SavedObjectAttributes>(type: string, id: string) => Promise<SimpleSavedObject<T>> | Fetches a single object | ## Methods diff --git a/docs/development/core/server/kibana-plugin-server.clusterclient.asscoped.md b/docs/development/core/server/kibana-plugin-server.clusterclient.asscoped.md index ed7d028a1ec8a..bb1f481c9ef4f 100644 --- a/docs/development/core/server/kibana-plugin-server.clusterclient.asscoped.md +++ b/docs/development/core/server/kibana-plugin-server.clusterclient.asscoped.md @@ -9,14 +9,14 @@ Creates an instance of [IScopedClusterClient](./kibana-plugin-server.iscopedclus Signature: ```typescript -asScoped(request?: KibanaRequest | LegacyRequest | FakeRequest): IScopedClusterClient; +asScoped(request?: ScopeableRequest): IScopedClusterClient; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| request | KibanaRequest | LegacyRequest | FakeRequest | Request the IScopedClusterClient instance will be scoped to. Supports request optionality, Legacy.Request & FakeRequest for BWC with LegacyPlatform | +| request | ScopeableRequest | Request the IScopedClusterClient instance will be scoped to. Supports request optionality, Legacy.Request & FakeRequest for BWC with LegacyPlatform | Returns: diff --git a/docs/development/core/server/kibana-plugin-server.clusterclient.md b/docs/development/core/server/kibana-plugin-server.clusterclient.md index 5fdda7ef3e499..d547b846e65b7 100644 --- a/docs/development/core/server/kibana-plugin-server.clusterclient.md +++ b/docs/development/core/server/kibana-plugin-server.clusterclient.md @@ -4,7 +4,7 @@ ## ClusterClient class -Represents an Elasticsearch cluster API client and allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). +Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). See [ClusterClient](./kibana-plugin-server.clusterclient.md). diff --git a/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.adminclient.md b/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.adminclient.md new file mode 100644 index 0000000000000..415423f555266 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.adminclient.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) > [adminClient](./kibana-plugin-server.elasticsearchservicesetup.adminclient.md) + +## ElasticsearchServiceSetup.adminClient property + +A client for the `admin` cluster. All Elasticsearch config value changes are processed under the hood. See [IClusterClient](./kibana-plugin-server.iclusterclient.md). + +Signature: + +```typescript +readonly adminClient: IClusterClient; +``` + +## Example + + +```js +const client = core.elasticsearch.adminClient; + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.adminclient_.md b/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.adminclient_.md deleted file mode 100644 index b5bfc68d3ca0c..0000000000000 --- a/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.adminclient_.md +++ /dev/null @@ -1,19 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) > [adminClient$](./kibana-plugin-server.elasticsearchservicesetup.adminclient_.md) - -## ElasticsearchServiceSetup.adminClient$ property - -Observable of clients for the `admin` cluster. Observable emits when Elasticsearch config changes on the Kibana server. See [IClusterClient](./kibana-plugin-server.iclusterclient.md). - - -```js -const client = await elasticsearch.adminClient$.pipe(take(1)).toPromise(); - -``` - -Signature: - -```typescript -readonly adminClient$: Observable; -``` diff --git a/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.createclient.md b/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.createclient.md index 3d26f2d4cec88..797f402cc2580 100644 --- a/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.createclient.md +++ b/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.createclient.md @@ -9,7 +9,7 @@ Create application specific Elasticsearch cluster API client with customized con Signature: ```typescript -readonly createClient: (type: string, clientConfig?: Partial) => IClusterClient; +readonly createClient: (type: string, clientConfig?: Partial) => ICustomClusterClient; ``` ## Example diff --git a/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.dataclient.md b/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.dataclient.md new file mode 100644 index 0000000000000..e9845dce6915d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.dataclient.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) > [dataClient](./kibana-plugin-server.elasticsearchservicesetup.dataclient.md) + +## ElasticsearchServiceSetup.dataClient property + +A client for the `data` cluster. All Elasticsearch config value changes are processed under the hood. See [IClusterClient](./kibana-plugin-server.iclusterclient.md). + +Signature: + +```typescript +readonly dataClient: IClusterClient; +``` + +## Example + + +```js +const client = core.elasticsearch.dataClient; + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.dataclient_.md b/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.dataclient_.md deleted file mode 100644 index 9411f2f6b8694..0000000000000 --- a/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.dataclient_.md +++ /dev/null @@ -1,19 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) > [dataClient$](./kibana-plugin-server.elasticsearchservicesetup.dataclient_.md) - -## ElasticsearchServiceSetup.dataClient$ property - -Observable of clients for the `data` cluster. Observable emits when Elasticsearch config changes on the Kibana server. See [IClusterClient](./kibana-plugin-server.iclusterclient.md). - - -```js -const client = await elasticsearch.dataClient$.pipe(take(1)).toPromise(); - -``` - -Signature: - -```typescript -readonly dataClient$: Observable; -``` diff --git a/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.md b/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.md index e3d151cdc0d8b..2de3f6e6d1bbc 100644 --- a/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.md +++ b/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.md @@ -15,17 +15,7 @@ export interface ElasticsearchServiceSetup | Property | Type | Description | | --- | --- | --- | -| [adminClient$](./kibana-plugin-server.elasticsearchservicesetup.adminclient_.md) | Observable<IClusterClient> | Observable of clients for the admin cluster. Observable emits when Elasticsearch config changes on the Kibana server. See [IClusterClient](./kibana-plugin-server.iclusterclient.md). -```js -const client = await elasticsearch.adminClient$.pipe(take(1)).toPromise(); - -``` - | -| [createClient](./kibana-plugin-server.elasticsearchservicesetup.createclient.md) | (type: string, clientConfig?: Partial<ElasticsearchClientConfig>) => IClusterClient | Create application specific Elasticsearch cluster API client with customized config. See [IClusterClient](./kibana-plugin-server.iclusterclient.md). | -| [dataClient$](./kibana-plugin-server.elasticsearchservicesetup.dataclient_.md) | Observable<IClusterClient> | Observable of clients for the data cluster. Observable emits when Elasticsearch config changes on the Kibana server. See [IClusterClient](./kibana-plugin-server.iclusterclient.md). -```js -const client = await elasticsearch.dataClient$.pipe(take(1)).toPromise(); - -``` - | +| [adminClient](./kibana-plugin-server.elasticsearchservicesetup.adminclient.md) | IClusterClient | A client for the admin cluster. All Elasticsearch config value changes are processed under the hood. See [IClusterClient](./kibana-plugin-server.iclusterclient.md). | +| [createClient](./kibana-plugin-server.elasticsearchservicesetup.createclient.md) | (type: string, clientConfig?: Partial<ElasticsearchClientConfig>) => ICustomClusterClient | Create application specific Elasticsearch cluster API client with customized config. See [IClusterClient](./kibana-plugin-server.iclusterclient.md). | +| [dataClient](./kibana-plugin-server.elasticsearchservicesetup.dataclient.md) | IClusterClient | A client for the data cluster. All Elasticsearch config value changes are processed under the hood. See [IClusterClient](./kibana-plugin-server.iclusterclient.md). | diff --git a/docs/development/core/server/kibana-plugin-server.iclusterclient.md b/docs/development/core/server/kibana-plugin-server.iclusterclient.md index 834afa6db5157..e7435a9d91a74 100644 --- a/docs/development/core/server/kibana-plugin-server.iclusterclient.md +++ b/docs/development/core/server/kibana-plugin-server.iclusterclient.md @@ -4,12 +4,12 @@ ## IClusterClient type -Represents an Elasticsearch cluster API client and allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). +Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). See [ClusterClient](./kibana-plugin-server.clusterclient.md). Signature: ```typescript -export declare type IClusterClient = Pick; +export declare type IClusterClient = Pick; ``` diff --git a/docs/development/core/server/kibana-plugin-server.icustomclusterclient.md b/docs/development/core/server/kibana-plugin-server.icustomclusterclient.md new file mode 100644 index 0000000000000..bc9ea71bc2389 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.icustomclusterclient.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [ICustomClusterClient](./kibana-plugin-server.icustomclusterclient.md) + +## ICustomClusterClient type + +Represents an Elasticsearch cluster API client created by a plugin.. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). + +See [ClusterClient](./kibana-plugin-server.clusterclient.md). + +Signature: + +```typescript +export declare type ICustomClusterClient = Pick; +``` diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 5e7f84c55244d..3f01048846fe1 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -17,7 +17,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | Class | Description | | --- | --- | | [BasePath](./kibana-plugin-server.basepath.md) | Access or manipulate the Kibana base path | -| [ClusterClient](./kibana-plugin-server.clusterclient.md) | Represents an Elasticsearch cluster API client and allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-server.clusterclient.md). | +| [ClusterClient](./kibana-plugin-server.clusterclient.md) | Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-server.clusterclient.md). | | [CspConfig](./kibana-plugin-server.cspconfig.md) | CSP configuration for use in Kibana. | | [ElasticsearchErrorHelpers](./kibana-plugin-server.elasticsearcherrorhelpers.md) | Helpers for working with errors returned from the Elasticsearch service.Since the internal data of errors are subject to change, consumers of the Elasticsearch service should always use these helpers to classify errors instead of checking error internals such as body.error.header[WWW-Authenticate] | | [KibanaRequest](./kibana-plugin-server.kibanarequest.md) | Kibana specific abstraction for an incoming request. | @@ -175,8 +175,9 @@ The plugin integrates with the core system via lifecycle events: `setup` | [Headers](./kibana-plugin-server.headers.md) | Http request headers to read. | | [HttpResponsePayload](./kibana-plugin-server.httpresponsepayload.md) | Data send to the client as a response payload. | | [IBasePath](./kibana-plugin-server.ibasepath.md) | Access or manipulate the Kibana base path[BasePath](./kibana-plugin-server.basepath.md) | -| [IClusterClient](./kibana-plugin-server.iclusterclient.md) | Represents an Elasticsearch cluster API client and allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-server.clusterclient.md). | +| [IClusterClient](./kibana-plugin-server.iclusterclient.md) | Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-server.clusterclient.md). | | [IContextProvider](./kibana-plugin-server.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | +| [ICustomClusterClient](./kibana-plugin-server.icustomclusterclient.md) | Represents an Elasticsearch cluster API client created by a plugin.. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-server.clusterclient.md). | | [IsAuthenticated](./kibana-plugin-server.isauthenticated.md) | Return authentication status for a request. | | [ISavedObjectsRepository](./kibana-plugin-server.isavedobjectsrepository.md) | See [SavedObjectsRepository](./kibana-plugin-server.savedobjectsrepository.md) | | [IScopedClusterClient](./kibana-plugin-server.iscopedclusterclient.md) | Serves the same purpose as "normal" ClusterClient but exposes additional callAsCurrentUser method that doesn't use credentials of the Kibana internal user (as callAsInternalUser does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API.See [ScopedClusterClient](./kibana-plugin-server.scopedclusterclient.md). | @@ -213,6 +214,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsClientContract](./kibana-plugin-server.savedobjectsclientcontract.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state.\#\# SavedObjectsClient errorsSince the SavedObjectsClient has its hands in everything we are a little paranoid about the way we present errors back to to application code. Ideally, all errors will be either:1. Caused by bad implementation (ie. undefined is not a function) and as such unpredictable 2. An error that has been classified and decorated appropriately by the decorators in [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md)Type 1 errors are inevitable, but since all expected/handle-able errors should be Type 2 the isXYZError() helpers exposed at SavedObjectsErrorHelpers should be used to understand and manage error responses from the SavedObjectsClient.Type 2 errors are decorated versions of the source error, so if the elasticsearch client threw an error it will be decorated based on its type. That means that rather than looking for error.body.error.type or doing substring checks on error.body.error.reason, just use the helpers to understand the meaning of the error:\`\`\`js if (SavedObjectsErrorHelpers.isNotFoundError(error)) { // handle 404 }if (SavedObjectsErrorHelpers.isNotAuthorizedError(error)) { // 401 handling should be automatic, but in case you wanted to know }// always rethrow the error unless you handle it throw error; \`\`\`\#\#\# 404s from missing indexFrom the perspective of application code and APIs the SavedObjectsClient is a black box that persists objects. One of the internal details that users have no control over is that we use an elasticsearch index for persistance and that index might be missing.At the time of writing we are in the process of transitioning away from the operating assumption that the SavedObjects index is always available. Part of this transition is handling errors resulting from an index missing. These used to trigger a 500 error in most cases, and in others cause 404s with different error messages.From my (Spencer) perspective, a 404 from the SavedObjectsApi is a 404; The object the request/call was targeting could not be found. This is why \#14141 takes special care to ensure that 404 errors are generic and don't distinguish between index missing or document missing.\#\#\# 503s from missing indexUnlike all other methods, create requests are supposed to succeed even when the Kibana index does not exist because it will be automatically created by elasticsearch. When that is not the case it is because Elasticsearch's action.auto_create_index setting prevents it from being created automatically so we throw a special 503 with the intention of informing the user that their Elasticsearch settings need to be updated.See [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) See [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md) | | [SavedObjectsClientFactory](./kibana-plugin-server.savedobjectsclientfactory.md) | Describes the factory used to create instances of the Saved Objects Client. | | [SavedObjectsClientWrapperFactory](./kibana-plugin-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. | +| [ScopeableRequest](./kibana-plugin-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-server.kibanarequest.md). | | [SharedGlobalConfig](./kibana-plugin-server.sharedglobalconfig.md) | | | [UiSettingsType](./kibana-plugin-server.uisettingstype.md) | UI element type to represent the settings. | diff --git a/docs/development/core/server/kibana-plugin-server.scopeablerequest.md b/docs/development/core/server/kibana-plugin-server.scopeablerequest.md new file mode 100644 index 0000000000000..5a9443376996d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.scopeablerequest.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [ScopeableRequest](./kibana-plugin-server.scopeablerequest.md) + +## ScopeableRequest type + +A user credentials container. It accommodates the necessary auth credentials to impersonate the current user. + +See [KibanaRequest](./kibana-plugin-server.kibanarequest.md). + +Signature: + +```typescript +export declare type ScopeableRequest = KibanaRequest | LegacyRequest | FakeRequest; +``` diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index 1c78de966c46f..b70ac610f24a7 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -1194,6 +1194,7 @@ In server code, `core` can be accessed from either `server.newPlatform` or `kbnS | `request.getBasePath()` | [`core.http.basePath.get`](/docs/development/core/server/kibana-plugin-server.httpservicesetup.basepath.md) | | | `server.plugins.elasticsearch.getCluster('data')` | [`context.elasticsearch.dataClient`](/docs/development/core/server/kibana-plugin-server.iscopedclusterclient.md) | | | `server.plugins.elasticsearch.getCluster('admin')` | [`context.elasticsearch.adminClient`](/docs/development/core/server/kibana-plugin-server.iscopedclusterclient.md) | | +| `server.plugins.elasticsearch.createCluster(...)` | [`core.elasticsearch.createClient`](/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.createclient.md) | | | `server.savedObjects.setScopedSavedObjectsClientFactory` | [`core.savedObjects.setClientFactory`](/docs/development/core/server/kibana-plugin-server.savedobjectsservicesetup.setclientfactory.md) | | | `server.savedObjects.addScopedSavedObjectsClientWrapperFactory` | [`core.savedObjects.addClientWrapper`](/docs/development/core/server/kibana-plugin-server.savedobjectsservicesetup.addclientwrapper.md) | | | `server.savedObjects.getSavedObjectsRepository` | [`core.savedObjects.createInternalRepository`](/docs/development/core/server/kibana-plugin-server.savedobjectsservicesetup.createinternalrepository.md) [`core.savedObjects.createScopedRepository`](/docs/development/core/server/kibana-plugin-server.savedobjectsservicesetup.createscopedrepository.md) | | diff --git a/src/core/server/elasticsearch/cluster_client.ts b/src/core/server/elasticsearch/cluster_client.ts index d43ab9d546ed2..2352677b8d3e0 100644 --- a/src/core/server/elasticsearch/cluster_client.ts +++ b/src/core/server/elasticsearch/cluster_client.ts @@ -89,15 +89,35 @@ export interface FakeRequest { } /** - * Represents an Elasticsearch cluster API client and allows to call API on behalf - * of the internal Kibana user and the actual user that is derived from the request - * headers (via `asScoped(...)`). + * Represents an Elasticsearch cluster API client created by the platform. + * It allows to call API on behalf of the internal Kibana user and + * the actual user that is derived from the request headers (via `asScoped(...)`). * * See {@link ClusterClient}. * * @public */ -export type IClusterClient = Pick; +export type IClusterClient = Pick; + +/** + * Represents an Elasticsearch cluster API client created by a plugin. + * It allows to call API on behalf of the internal Kibana user and + * the actual user that is derived from the request headers (via `asScoped(...)`). + * + * See {@link ClusterClient}. + * + * @public + */ +export type ICustomClusterClient = Pick; + +/** + A user credentials container. + * It accommodates the necessary auth credentials to impersonate the current user. + * + * @public + * See {@link KibanaRequest}. + */ +export type ScopeableRequest = KibanaRequest | LegacyRequest | FakeRequest; /** * {@inheritDoc IClusterClient} @@ -174,7 +194,7 @@ export class ClusterClient implements IClusterClient { * @param request - Request the `IScopedClusterClient` instance will be scoped to. * Supports request optionality, Legacy.Request & FakeRequest for BWC with LegacyPlatform */ - public asScoped(request?: KibanaRequest | LegacyRequest | FakeRequest): IScopedClusterClient { + public asScoped(request?: ScopeableRequest): IScopedClusterClient { // It'd have been quite expensive to create and configure client for every incoming // request since it involves parsing of the config, reading of the SSL certificate and // key files etc. Moreover scoped client needs two Elasticsearch JS clients at the same diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index d935d1a66eccf..1b52f22c4da09 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -18,33 +18,67 @@ */ import { BehaviorSubject } from 'rxjs'; -import { IClusterClient } from './cluster_client'; +import { IClusterClient, ICustomClusterClient } from './cluster_client'; import { IScopedClusterClient } from './scoped_cluster_client'; import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchService } from './elasticsearch_service'; -import { InternalElasticsearchServiceSetup } from './types'; +import { InternalElasticsearchServiceSetup, ElasticsearchServiceSetup } from './types'; const createScopedClusterClientMock = (): jest.Mocked => ({ callAsInternalUser: jest.fn(), callAsCurrentUser: jest.fn(), }); -const createClusterClientMock = (): jest.Mocked => ({ - callAsInternalUser: jest.fn(), - asScoped: jest.fn().mockImplementation(createScopedClusterClientMock), +const createCustomClusterClientMock = (): jest.Mocked => ({ + ...createClusterClientMock(), close: jest.fn(), }); +function createClusterClientMock() { + const client: jest.Mocked = { + callAsInternalUser: jest.fn(), + asScoped: jest.fn(), + }; + client.asScoped.mockReturnValue(createScopedClusterClientMock()); + return client; +} + +type MockedElasticSearchServiceSetup = jest.Mocked< + ElasticsearchServiceSetup & { + adminClient: jest.Mocked; + dataClient: jest.Mocked; + } +>; + const createSetupContractMock = () => { - const setupContract: jest.Mocked = { + const setupContract: MockedElasticSearchServiceSetup = { + createClient: jest.fn(), + adminClient: createClusterClientMock(), + dataClient: createClusterClientMock(), + }; + setupContract.createClient.mockReturnValue(createCustomClusterClientMock()); + setupContract.adminClient.asScoped.mockReturnValue(createScopedClusterClientMock()); + setupContract.dataClient.asScoped.mockReturnValue(createScopedClusterClientMock()); + return setupContract; +}; + +type MockedInternalElasticSearchServiceSetup = jest.Mocked< + InternalElasticsearchServiceSetup & { + adminClient: jest.Mocked; + dataClient: jest.Mocked; + } +>; +const createInternalSetupContractMock = () => { + const setupContract: MockedInternalElasticSearchServiceSetup = { + ...createSetupContractMock(), legacy: { config$: new BehaviorSubject({} as ElasticsearchConfig), }, - - createClient: jest.fn().mockImplementation(createClusterClientMock), - adminClient$: new BehaviorSubject((createClusterClientMock() as unknown) as IClusterClient), - dataClient$: new BehaviorSubject((createClusterClientMock() as unknown) as IClusterClient), + adminClient$: new BehaviorSubject(createClusterClientMock()), + dataClient$: new BehaviorSubject(createClusterClientMock()), }; + setupContract.adminClient.asScoped.mockReturnValue(createScopedClusterClientMock()); + setupContract.dataClient.asScoped.mockReturnValue(createScopedClusterClientMock()); return setupContract; }; @@ -55,14 +89,16 @@ const createMock = () => { start: jest.fn(), stop: jest.fn(), }; - mocked.setup.mockResolvedValue(createSetupContractMock()); + mocked.setup.mockResolvedValue(createInternalSetupContractMock()); mocked.stop.mockResolvedValue(); return mocked; }; export const elasticsearchServiceMock = { create: createMock, - createSetupContract: createSetupContractMock, + createInternalSetup: createInternalSetupContractMock, + createSetup: createSetupContractMock, createClusterClient: createClusterClientMock, + createCustomClusterClient: createCustomClusterClientMock, createScopedClusterClient: createScopedClusterClientMock, }; diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index 6c4a1f263bc71..aefc8b1fdf047 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -30,6 +30,7 @@ import { loggingServiceMock } from '../logging/logging_service.mock'; import { httpServiceMock } from '../http/http_service.mock'; import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchService } from './elasticsearch_service'; +import { elasticsearchServiceMock } from './elasticsearch_service.mock'; let elasticsearchService: ElasticsearchService; const configService = configServiceMock.create(); @@ -69,6 +70,27 @@ describe('#setup', () => { ); }); + it('returns data and admin client as a part of the contract', async () => { + const mockAdminClusterClientInstance = elasticsearchServiceMock.createClusterClient(); + const mockDataClusterClientInstance = elasticsearchServiceMock.createClusterClient(); + MockClusterClient.mockImplementationOnce( + () => mockAdminClusterClientInstance + ).mockImplementationOnce(() => mockDataClusterClientInstance); + + const setupContract = await elasticsearchService.setup(deps); + + const adminClient = setupContract.adminClient; + const dataClient = setupContract.dataClient; + + expect(mockAdminClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(0); + await adminClient.callAsInternalUser('any'); + expect(mockAdminClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); + + expect(mockDataClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(0); + await dataClient.callAsInternalUser('any'); + expect(mockDataClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); + }); + it('returns data and admin client observables as a part of the contract', async () => { const mockAdminClusterClientInstance = { close: jest.fn() }; const mockDataClusterClientInstance = { close: jest.fn() }; diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index be0a817c54146..de32e7f6cf225 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -18,17 +18,18 @@ */ import { ConnectableObservable, Observable, Subscription } from 'rxjs'; -import { filter, first, map, publishReplay, switchMap } from 'rxjs/operators'; +import { filter, first, map, publishReplay, switchMap, take } from 'rxjs/operators'; import { CoreService } from '../../types'; import { merge } from '../../utils'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; -import { ClusterClient } from './cluster_client'; +import { ClusterClient, ScopeableRequest } from './cluster_client'; import { ElasticsearchClientConfig } from './elasticsearch_client_config'; import { ElasticsearchConfig, ElasticsearchConfigType } from './elasticsearch_config'; import { InternalHttpServiceSetup, GetAuthHeaders } from '../http/'; import { InternalElasticsearchServiceSetup } from './types'; +import { CallAPIOptions } from './api_types'; /** @internal */ interface CoreClusterClients { @@ -94,11 +95,67 @@ export class ElasticsearchService implements CoreService clients.adminClient)); + const dataClient$ = clients$.pipe(map(clients => clients.dataClient)); + + const adminClient = { + async callAsInternalUser( + endpoint: string, + clientParams: Record = {}, + options?: CallAPIOptions + ) { + const client = await adminClient$.pipe(take(1)).toPromise(); + return await client.callAsInternalUser(endpoint, clientParams, options); + }, + asScoped(request: ScopeableRequest) { + return { + callAsInternalUser: adminClient.callAsInternalUser, + async callAsCurrentUser( + endpoint: string, + clientParams: Record = {}, + options?: CallAPIOptions + ) { + const client = await adminClient$.pipe(take(1)).toPromise(); + return await client + .asScoped(request) + .callAsCurrentUser(endpoint, clientParams, options); + }, + }; + }, + }; + const dataClient = { + async callAsInternalUser( + endpoint: string, + clientParams: Record = {}, + options?: CallAPIOptions + ) { + const client = await dataClient$.pipe(take(1)).toPromise(); + return await client.callAsInternalUser(endpoint, clientParams, options); + }, + asScoped(request: ScopeableRequest) { + return { + callAsInternalUser: dataClient.callAsInternalUser, + async callAsCurrentUser( + endpoint: string, + clientParams: Record = {}, + options?: CallAPIOptions + ) { + const client = await dataClient$.pipe(take(1)).toPromise(); + return await client + .asScoped(request) + .callAsCurrentUser(endpoint, clientParams, options); + }, + }; + }, + }; + return { legacy: { config$: clients$.pipe(map(clients => clients.config)) }, - adminClient$: clients$.pipe(map(clients => clients.adminClient)), - dataClient$: clients$.pipe(map(clients => clients.dataClient)), + adminClient$, + dataClient$, + adminClient, + dataClient, createClient: (type: string, clientConfig: Partial = {}) => { const finalConfig = merge({}, config, clientConfig); diff --git a/src/core/server/elasticsearch/index.ts b/src/core/server/elasticsearch/index.ts index 1f99f86d9887b..5d64fadfaa184 100644 --- a/src/core/server/elasticsearch/index.ts +++ b/src/core/server/elasticsearch/index.ts @@ -18,7 +18,13 @@ */ export { ElasticsearchService } from './elasticsearch_service'; -export { IClusterClient, ClusterClient, FakeRequest } from './cluster_client'; +export { + ClusterClient, + FakeRequest, + IClusterClient, + ICustomClusterClient, + ScopeableRequest, +} from './cluster_client'; export { IScopedClusterClient, ScopedClusterClient, Headers } from './scoped_cluster_client'; export { ElasticsearchClientConfig } from './elasticsearch_client_config'; export { config } from './elasticsearch_config'; diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 505b57c7c9e8e..22340bf3f2fc6 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -20,7 +20,7 @@ import { Observable } from 'rxjs'; import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchClientConfig } from './elasticsearch_client_config'; -import { IClusterClient } from './cluster_client'; +import { IClusterClient, ICustomClusterClient } from './cluster_client'; /** * @public @@ -46,29 +46,29 @@ export interface ElasticsearchServiceSetup { readonly createClient: ( type: string, clientConfig?: Partial - ) => IClusterClient; + ) => ICustomClusterClient; /** - * Observable of clients for the `admin` cluster. Observable emits when Elasticsearch config changes on the Kibana - * server. See {@link IClusterClient}. + * A client for the `admin` cluster. All Elasticsearch config value changes are processed under the hood. + * See {@link IClusterClient}. * - * @exmaple + * @example * ```js - * const client = await elasticsearch.adminClient$.pipe(take(1)).toPromise(); + * const client = core.elasticsearch.adminClient; * ``` */ - readonly adminClient$: Observable; + readonly adminClient: IClusterClient; /** - * Observable of clients for the `data` cluster. Observable emits when Elasticsearch config changes on the Kibana - * server. See {@link IClusterClient}. + * A client for the `data` cluster. All Elasticsearch config value changes are processed under the hood. + * See {@link IClusterClient}. * - * @exmaple + * @example * ```js - * const client = await elasticsearch.dataClient$.pipe(take(1)).toPromise(); + * const client = core.elasticsearch.dataClient; * ``` */ - readonly dataClient$: Observable; + readonly dataClient: IClusterClient; } /** @internal */ @@ -77,4 +77,7 @@ export interface InternalElasticsearchServiceSetup extends ElasticsearchServiceS readonly legacy: { readonly config$: Observable; }; + + readonly adminClient$: Observable; + readonly dataClient$: Observable; } diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 953fa0738597c..eccf3985fc495 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -74,6 +74,7 @@ export { CspConfig, ICspConfig } from './csp'; export { ClusterClient, IClusterClient, + ICustomClusterClient, Headers, ScopedClusterClient, IScopedClusterClient, @@ -83,6 +84,7 @@ export { ElasticsearchServiceSetup, APICaller, FakeRequest, + ScopeableRequest, } from './elasticsearch'; export * from './elasticsearch/api_types'; export { diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index e17de7364ce59..cc36b90ec526d 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -249,8 +249,8 @@ export class LegacyService implements CoreService { capabilities: setupDeps.core.capabilities, context: setupDeps.core.context, elasticsearch: { - adminClient$: setupDeps.core.elasticsearch.adminClient$, - dataClient$: setupDeps.core.elasticsearch.dataClient$, + adminClient: setupDeps.core.elasticsearch.adminClient, + dataClient: setupDeps.core.elasticsearch.dataClient, createClient: setupDeps.core.elasticsearch.createClient, }, http: { diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 53849b040c413..073d380d3aa67 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -107,7 +107,7 @@ function createCoreSetupMock() { const mock: MockedKeys = { capabilities: capabilitiesServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetup(), http: httpMock, savedObjects: savedObjectsServiceMock.createSetupContract(), uiSettings: uiSettingsMock, @@ -131,7 +131,7 @@ function createInternalCoreSetupMock() { const setupDeps: InternalCoreSetup = { capabilities: capabilitiesServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createInternalSetup(), http: httpServiceMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), savedObjects: savedObjectsServiceMock.createSetupContract(), diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 6e9a7967e9eca..6d82a8d3ec6cf 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -145,8 +145,8 @@ export function createPluginSetupContext( createContextContainer: deps.context.createContextContainer, }, elasticsearch: { - adminClient$: deps.elasticsearch.adminClient$, - dataClient$: deps.elasticsearch.dataClient$, + adminClient: deps.elasticsearch.adminClient, + dataClient: deps.elasticsearch.dataClient, createClient: deps.elasticsearch.createClient, }, http: { 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 f58939c58e85e..deb6f98e17b7b 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -23,7 +23,6 @@ import { SavedObjectsService, SavedObjectsSetupDeps } from './saved_objects_serv import { mockCoreContext } from '../core_context.mock'; // @ts-ignore Typescript doesn't know about the jest mock import { KibanaMigrator, mockKibanaMigratorInstance } from './migrations/kibana/kibana_migrator'; -import { of } from 'rxjs'; import * as legacyElasticsearch from 'elasticsearch'; import { Env } from '../config'; import { configServiceMock } from '../mocks'; @@ -49,7 +48,7 @@ describe('SavedObjectsService', () => { const soService = new SavedObjectsService(coreContext); const coreSetup = ({ - elasticsearch: { adminClient$: of(clusterClient) }, + elasticsearch: { adminClient: clusterClient }, legacyPlugins: { uiExports: { savedObjectMappings: [] }, pluginExtendedConfig: {} }, } as unknown) as SavedObjectsSetupDeps; @@ -68,7 +67,7 @@ describe('SavedObjectsService', () => { }); const soService = new SavedObjectsService(coreContext); const coreSetup = ({ - elasticsearch: { adminClient$: of({ callAsInternalUser: jest.fn() }) }, + elasticsearch: { adminClient: { callAsInternalUser: jest.fn() } }, legacyPlugins: { uiExports: {}, pluginExtendedConfig: {} }, } as unknown) as SavedObjectsSetupDeps; @@ -82,7 +81,7 @@ describe('SavedObjectsService', () => { const coreContext = mockCoreContext.create({ configService }); const soService = new SavedObjectsService(coreContext); const coreSetup = ({ - elasticsearch: { adminClient$: of({ callAsInternalUser: jest.fn() }) }, + elasticsearch: { adminClient: { callAsInternalUser: jest.fn() } }, legacyPlugins: { uiExports: {}, pluginExtendedConfig: {} }, } as unknown) as SavedObjectsSetupDeps; @@ -96,7 +95,7 @@ describe('SavedObjectsService', () => { const coreContext = mockCoreContext.create({ configService }); const soService = new SavedObjectsService(coreContext); const coreSetup = ({ - elasticsearch: { adminClient$: of({ callAsInternalUser: jest.fn() }) }, + elasticsearch: { adminClient: { callAsInternalUser: jest.fn() } }, legacyPlugins: { uiExports: {}, pluginExtendedConfig: {} }, } as unknown) as SavedObjectsSetupDeps; diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 5079b4f633df2..69ca8306ca4da 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -29,7 +29,7 @@ import { import { KibanaMigrator, IKibanaMigrator } from './migrations'; import { CoreContext } from '../core_context'; import { LegacyServiceDiscoverPlugins } from '../legacy'; -import { ElasticsearchServiceSetup, APICaller } from '../elasticsearch'; +import { InternalElasticsearchServiceSetup, APICaller } from '../elasticsearch'; import { KibanaConfigType } from '../kibana_config'; import { migrationsRetryCallCluster } from '../elasticsearch/retry_call_cluster'; import { SavedObjectsConfigType } from './saved_objects_config'; @@ -174,7 +174,7 @@ export interface InternalSavedObjectsServiceStart extends SavedObjectsServiceSta /** @internal */ export interface SavedObjectsSetupDeps { legacyPlugins: LegacyServiceDiscoverPlugins; - elasticsearch: ElasticsearchServiceSetup; + elasticsearch: InternalElasticsearchServiceSetup; } /** @internal */ @@ -206,8 +206,6 @@ export class SavedObjectsService const savedObjectSchemas = new SavedObjectsSchema(savedObjectsSchemasDefinition); - const adminClient = await setupDeps.elasticsearch.adminClient$.pipe(first()).toPromise(); - const kibanaConfig = await this.coreContext.configService .atPath('kibana') .pipe(first()) @@ -218,6 +216,8 @@ export class SavedObjectsService .pipe(first()) .toPromise(); + const adminClient = setupDeps.elasticsearch.adminClient; + const migrator = (this.migrator = new KibanaMigrator({ savedObjectSchemas, savedObjectMappings, diff --git a/src/core/server/saved_objects/service/lib/scoped_client_provider.ts b/src/core/server/saved_objects/service/lib/scoped_client_provider.ts index 0b67727455333..42f75e2517126 100644 --- a/src/core/server/saved_objects/service/lib/scoped_client_provider.ts +++ b/src/core/server/saved_objects/service/lib/scoped_client_provider.ts @@ -66,12 +66,6 @@ export type ISavedObjectsClientProvider = Pick< * Provider for the Scoped Saved Objects Client. * * @internal - * - * @internalRemarks Because `getClient` is synchronous the Client Provider does - * not support creating factories that react to new ES clients emitted from - * elasticsearch.adminClient$. The Client Provider therefore doesn't support - * configuration changes to the Elasticsearch client. TODO: revisit once we've - * closed https://github.com/elastic/kibana/pull/45796 */ export class SavedObjectsClientProvider { private readonly _wrapperFactories = new PriorityCollection<{ diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 35359c5d6c417..e41f045622a42 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -498,7 +498,7 @@ export type CapabilitiesSwitcher = (request: KibanaRequest, uiCapabilities: Capa // @public export class ClusterClient implements IClusterClient { constructor(config: ElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders); - asScoped(request?: KibanaRequest | LegacyRequest | FakeRequest): IScopedClusterClient; + asScoped(request?: ScopeableRequest): IScopedClusterClient; callAsInternalUser: APICaller; close(): void; } @@ -669,9 +669,9 @@ export class ElasticsearchErrorHelpers { // @public (undocumented) export interface ElasticsearchServiceSetup { - readonly adminClient$: Observable; - readonly createClient: (type: string, clientConfig?: Partial) => IClusterClient; - readonly dataClient$: Observable; + readonly adminClient: IClusterClient; + readonly createClient: (type: string, clientConfig?: Partial) => ICustomClusterClient; + readonly dataClient: IClusterClient; } // @public (undocumented) @@ -752,7 +752,7 @@ export interface HttpServiceStart { export type IBasePath = Pick; // @public -export type IClusterClient = Pick; +export type IClusterClient = Pick; // @public export interface IContextContainer> { @@ -771,6 +771,9 @@ export interface ICspConfig { readonly warnLegacyBrowsers: boolean; } +// @public +export type ICustomClusterClient = Pick; + // @public export interface IKibanaResponse { // (undocumented) @@ -1878,6 +1881,9 @@ export interface SavedObjectsUpdateResponse => { + // it consumes elasticsearch observables to provide the same client throughout the context lifetime. const adminClient = await coreSetup.elasticsearch.adminClient$.pipe(take(1)).toPromise(); const dataClient = await coreSetup.elasticsearch.dataClient$.pipe(take(1)).toPromise(); const savedObjectsClient = coreSetup.savedObjects.getScopedClient(req); @@ -226,8 +227,6 @@ export class Server { render: rendering.render.bind(rendering, req, uiSettingsClient), }, savedObjects: { - // Note: the client provider doesn't support new ES clients - // emitted from adminClient$ client: savedObjectsClient, }, elasticsearch: { diff --git a/src/legacy/core_plugins/elasticsearch/index.js b/src/legacy/core_plugins/elasticsearch/index.js index 7fc00e27e5b5f..da7b557e7ea19 100644 --- a/src/legacy/core_plugins/elasticsearch/index.js +++ b/src/legacy/core_plugins/elasticsearch/index.js @@ -16,9 +16,7 @@ * specific language governing permissions and limitations * under the License. */ - -import { combineLatest } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { first } from 'rxjs/operators'; import healthCheck from './lib/health_check'; import { Cluster } from './lib/cluster'; import { createProxy } from './lib/create_proxy'; @@ -36,19 +34,12 @@ export default function(kibana) { // All methods that ES plugin exposes are synchronous so we should get the first // value from all observables here to be able to synchronously return and create // cluster clients afterwards. - const [esConfig, adminCluster, dataCluster] = await combineLatest( - server.newPlatform.__internals.elasticsearch.legacy.config$, - server.newPlatform.setup.core.elasticsearch.adminClient$, - server.newPlatform.setup.core.elasticsearch.dataClient$ - ) - .pipe( - first(), - map(([config, adminClusterClient, dataClusterClient]) => [ - config, - new Cluster(adminClusterClient), - new Cluster(dataClusterClient), - ]) - ) + const { adminClient, dataClient } = server.newPlatform.setup.core.elasticsearch; + const adminCluster = new Cluster(adminClient); + const dataCluster = new Cluster(dataClient); + + const esConfig = await server.newPlatform.__internals.elasticsearch.legacy.config$ + .pipe(first()) .toPromise(); defaultVars = { diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.test.js b/src/legacy/server/saved_objects/saved_objects_mixin.test.js index 0e96189db4650..691878cf66d27 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.test.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.test.js @@ -106,12 +106,7 @@ describe('Saved Objects Mixin', () => { newPlatform: { __internals: { elasticsearch: { - adminClient$: { - pipe: jest.fn().mockImplementation(() => ({ - toPromise: () => - Promise.resolve({ adminClient: { callAsInternalUser: mockCallCluster } }), - })), - }, + adminClient: { callAsInternalUser: mockCallCluster }, }, }, }, diff --git a/src/plugins/testbed/server/index.ts b/src/plugins/testbed/server/index.ts index 586254603567b..4873fe0926472 100644 --- a/src/plugins/testbed/server/index.ts +++ b/src/plugins/testbed/server/index.ts @@ -17,7 +17,7 @@ * under the License. */ -import { map, mergeMap } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { schema, TypeOf } from '@kbn/config-schema'; import { @@ -108,9 +108,7 @@ class Plugin { return `Some exposed data derived from config: ${configValue.secret}`; }) ), - pingElasticsearch$: core.elasticsearch.adminClient$.pipe( - mergeMap(client => client.callAsInternalUser('ping')) - ), + pingElasticsearch: () => core.elasticsearch.adminClient.callAsInternalUser('ping'), }; } diff --git a/x-pack/legacy/plugins/actions/server/plugin.ts b/x-pack/legacy/plugins/actions/server/plugin.ts index e8bc5d60a697b..48f99ba5135b7 100644 --- a/x-pack/legacy/plugins/actions/server/plugin.ts +++ b/x-pack/legacy/plugins/actions/server/plugin.ts @@ -69,7 +69,7 @@ export class Plugin { plugins: ActionsPluginsSetup ): Promise { const config = await this.config$.pipe(first()).toPromise(); - this.adminClient = await core.elasticsearch.adminClient$.pipe(first()).toPromise(); + this.adminClient = core.elasticsearch.adminClient; this.defaultKibanaIndex = (await this.kibana$.pipe(first()).toPromise()).index; this.licenseState = new LicenseState(plugins.licensing.license$); diff --git a/x-pack/legacy/plugins/alerting/server/plugin.ts b/x-pack/legacy/plugins/alerting/server/plugin.ts index ede95f76bf811..fb16f579d4c70 100644 --- a/x-pack/legacy/plugins/alerting/server/plugin.ts +++ b/x-pack/legacy/plugins/alerting/server/plugin.ts @@ -5,7 +5,7 @@ */ import Hapi from 'hapi'; -import { first } from 'rxjs/operators'; + import { Services } from './types'; import { AlertsClient } from './alerts_client'; import { AlertTypeRegistry } from './alert_type_registry'; @@ -62,7 +62,7 @@ export class Plugin { core: AlertingCoreSetup, plugins: AlertingPluginsSetup ): Promise { - this.adminClient = await core.elasticsearch.adminClient$.pipe(first()).toPromise(); + this.adminClient = core.elasticsearch.adminClient; this.licenseState = new LicenseState(plugins.licensing.license$); diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/plugin.ts b/x-pack/legacy/plugins/watcher/server/np_ready/plugin.ts index 2e8c81efa19c0..a311c31082183 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/plugin.ts +++ b/x-pack/legacy/plugins/watcher/server/np_ready/plugin.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 { first } from 'rxjs/operators'; import { Plugin, CoreSetup } from 'src/core/server'; import { i18n } from '@kbn/i18n'; import { PLUGIN } from '../../common/constants'; @@ -23,7 +22,7 @@ export class WatcherServerPlugin implements Plugin { { http, elasticsearch: elasticsearchService }: CoreSetup, { __LEGACY: serverShim }: { __LEGACY: ServerShim } ) { - const elasticsearch = await elasticsearchService.adminClient$.pipe(first()).toPromise(); + const elasticsearch = await elasticsearchService.adminClient; const router = http.createRouter(); const routeDependencies: RouteDependencies = { elasticsearch, diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index a1cf2ae4e8ead..11d91efdf6b01 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -58,16 +58,14 @@ export class APMPlugin implements Plugin { }); await new Promise(resolve => { - combineLatest(mergedConfig$, core.elasticsearch.dataClient$).subscribe( - async ([config, dataClient]) => { - this.currentConfig = config; - await createApmAgentConfigurationIndex({ - esClient: dataClient, - config, - }); - resolve(); - } - ); + mergedConfig$.subscribe(async config => { + this.currentConfig = config; + await createApmAgentConfigurationIndex({ + esClient: core.elasticsearch.dataClient, + config, + }); + resolve(); + }); }); return { diff --git a/x-pack/plugins/licensing/server/plugin.test.ts b/x-pack/plugins/licensing/server/plugin.test.ts index 0b5a3533bd3b6..9547a2dc52966 100644 --- a/x-pack/plugins/licensing/server/plugin.test.ts +++ b/x-pack/plugins/licensing/server/plugin.test.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BehaviorSubject } from 'rxjs'; import { take, toArray } from 'rxjs/operators'; import moment from 'moment'; import { LicenseType } from '../common/types'; @@ -53,7 +52,7 @@ describe('licensing plugin', () => { features: {}, }); const coreSetup = coreMock.createSetup(); - coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + coreSetup.elasticsearch.dataClient = dataClient; const { license$ } = await plugin.setup(coreSetup); const license = await license$.pipe(take(1)).toPromise(); @@ -71,7 +70,7 @@ describe('licensing plugin', () => { }) ); const coreSetup = coreMock.createSetup(); - coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + coreSetup.elasticsearch.dataClient = dataClient; const { license$ } = await plugin.setup(coreSetup); const [first, second, third] = await license$.pipe(take(3), toArray()).toPromise(); @@ -85,7 +84,7 @@ describe('licensing plugin', () => { const dataClient = elasticsearchServiceMock.createClusterClient(); dataClient.callAsInternalUser.mockRejectedValue(new Error('test')); const coreSetup = coreMock.createSetup(); - coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + coreSetup.elasticsearch.dataClient = dataClient; const { license$ } = await plugin.setup(coreSetup); const license = await license$.pipe(take(1)).toPromise(); @@ -99,7 +98,7 @@ describe('licensing plugin', () => { error.status = 400; dataClient.callAsInternalUser.mockRejectedValue(error); const coreSetup = coreMock.createSetup(); - coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + coreSetup.elasticsearch.dataClient = dataClient; const { license$ } = await plugin.setup(coreSetup); const license = await license$.pipe(take(1)).toPromise(); @@ -119,7 +118,7 @@ describe('licensing plugin', () => { .mockResolvedValue({ license: buildRawLicense(), features: {} }); const coreSetup = coreMock.createSetup(); - coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + coreSetup.elasticsearch.dataClient = dataClient; const { license$ } = await plugin.setup(coreSetup); const [first, second, third] = await license$.pipe(take(3), toArray()).toPromise(); @@ -137,7 +136,7 @@ describe('licensing plugin', () => { }); const coreSetup = coreMock.createSetup(); - coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + coreSetup.elasticsearch.dataClient = dataClient; await plugin.setup(coreSetup); await flushPromises(); @@ -152,7 +151,7 @@ describe('licensing plugin', () => { }); const coreSetup = coreMock.createSetup(); - coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + coreSetup.elasticsearch.dataClient = dataClient; await plugin.setup(coreSetup); await flushPromises(); @@ -180,7 +179,7 @@ describe('licensing plugin', () => { ); const coreSetup = coreMock.createSetup(); - coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + coreSetup.elasticsearch.dataClient = dataClient; const { license$ } = await plugin.setup(coreSetup); const [first, second, third] = await license$.pipe(take(3), toArray()).toPromise(); @@ -209,7 +208,7 @@ describe('licensing plugin', () => { features: {}, }); const coreSetup = coreMock.createSetup(); - coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + coreSetup.elasticsearch.dataClient = dataClient; const { refresh } = await plugin.setup(coreSetup); expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(0); @@ -242,7 +241,7 @@ describe('licensing plugin', () => { features: {}, }); const coreSetup = coreMock.createSetup(); - coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + coreSetup.elasticsearch.dataClient = dataClient; const { createLicensePoller, license$ } = await plugin.setup(coreSetup); const customClient = elasticsearchServiceMock.createClusterClient(); diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index 2eabd534a997c..383245e6f4ee8 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -92,7 +92,7 @@ export class LicensingPlugin implements Plugin { this.logger.debug('Setting up Licensing plugin'); const config = await this.config$.pipe(take(1)).toPromise(); const pollingFrequency = config.api_polling_frequency; - const dataClient = await core.elasticsearch.dataClient$.pipe(take(1)).toPromise(); + const dataClient = await core.elasticsearch.dataClient; const { refresh, license$ } = this.createLicensePoller( dataClient, diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 05d67b112bad8..5e32a0e90198a 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -6,7 +6,7 @@ import { of } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; -import { IClusterClient, CoreSetup } from '../../../../src/core/server'; +import { ICustomClusterClient, CoreSetup } from '../../../../src/core/server'; import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; import { Plugin, PluginSetupDependencies } from './plugin'; @@ -15,7 +15,7 @@ import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/ describe('Security Plugin', () => { let plugin: Plugin; let mockCoreSetup: MockedKeys; - let mockClusterClient: jest.Mocked; + let mockClusterClient: jest.Mocked; let mockDependencies: PluginSetupDependencies; beforeEach(() => { plugin = new Plugin( @@ -35,9 +35,9 @@ describe('Security Plugin', () => { mockCoreSetup = coreMock.createSetup(); mockCoreSetup.http.isTlsEnabled = true; - mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockClusterClient = elasticsearchServiceMock.createCustomClusterClient(); mockCoreSetup.elasticsearch.createClient.mockReturnValue( - (mockClusterClient as unknown) as jest.Mocked + (mockClusterClient as unknown) as jest.Mocked ); mockDependencies = { licensing: { license$: of({}) } } as PluginSetupDependencies; diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 9c4b01f94ef4d..ce682d8b30eb7 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -7,7 +7,7 @@ import { combineLatest } from 'rxjs'; import { first } from 'rxjs/operators'; import { - IClusterClient, + ICustomClusterClient, CoreSetup, KibanaRequest, Logger, @@ -85,7 +85,7 @@ export interface PluginSetupDependencies { */ export class Plugin { private readonly logger: Logger; - private clusterClient?: IClusterClient; + private clusterClient?: ICustomClusterClient; private spacesService?: SpacesService | symbol = Symbol('not accessed'); private securityLicenseService?: SecurityLicenseService; diff --git a/x-pack/plugins/spaces/server/lib/create_default_space.ts b/x-pack/plugins/spaces/server/lib/create_default_space.ts index 2301fa26dab28..0d1a4ddab91bb 100644 --- a/x-pack/plugins/spaces/server/lib/create_default_space.ts +++ b/x-pack/plugins/spaces/server/lib/create_default_space.ts @@ -9,7 +9,7 @@ import { SavedObjectsLegacyService, IClusterClient } from 'src/core/server'; import { DEFAULT_SPACE_ID } from '../../common/constants'; interface Deps { - esClient: Pick; + esClient: IClusterClient; savedObjects: SavedObjectsLegacyService; } diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index 2b0cfd3687a24..c1f557f164ad6 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts @@ -168,7 +168,7 @@ describe('onPostAuthInterceptor', () => { const spacesService = await service.setup({ http: (http as unknown) as CoreSetup['http'], - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetup(), authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), diff --git a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts index 9e1bb5cf9f7d6..a3396e98c3512 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts @@ -50,7 +50,7 @@ describe('createSpacesTutorialContextFactory', () => { it('should create context with the current space id for the default space', async () => { const spacesService = await service.setup({ http: coreMock.createSetup().http, - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetup(), authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index dc4bc839c0e29..6b7699100032d 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -5,7 +5,6 @@ */ import { Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { HomeServerPluginSetup } from 'src/plugins/home/server'; import { @@ -168,9 +167,8 @@ export class Plugin { ); }, createDefaultSpace: async () => { - const esClient = await core.elasticsearch.adminClient$.pipe(take(1)).toPromise(); - return createDefaultSpace({ - esClient, + return await createDefaultSpace({ + esClient: core.elasticsearch.adminClient, savedObjects: this.getLegacyAPI().savedObjects, }); }, 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 4d8d08a487e9a..74197e6ca7556 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 @@ -43,7 +43,7 @@ describe('copy to space', () => { const service = new SpacesService(log, () => legacyAPI); const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetup(), authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), 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 28d5708a3873c..35f18cf66a57e 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 @@ -44,7 +44,7 @@ describe('Spaces Public API', () => { const service = new SpacesService(log, () => legacyAPI); const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetup(), authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), 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 f9bd4494791f1..3300e30825283 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 @@ -42,7 +42,7 @@ describe('GET space', () => { const service = new SpacesService(log, () => legacyAPI); const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetup(), authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), 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 02219db88a04c..ca89731f35946 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 @@ -42,7 +42,7 @@ describe('GET /spaces/space', () => { const service = new SpacesService(log, () => legacyAPI); const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetup(), authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), 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 d82ccaa8ff380..26ecbf2247e0f 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 @@ -43,7 +43,7 @@ describe('Spaces Public API', () => { const service = new SpacesService(log, () => legacyAPI); const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetup(), authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), 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 15837110f4d92..e6182e027b854 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 @@ -44,7 +44,7 @@ describe('PUT /api/spaces/space', () => { const service = new SpacesService(log, () => legacyAPI); const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetup(), authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), diff --git a/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts b/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts index 8f97df2aa0a50..461f816ff5019 100644 --- a/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts @@ -22,7 +22,7 @@ describe('GET /internal/spaces/_active_space', () => { const service = new SpacesService(null as any, () => legacyAPI); const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetup(), authorization: null, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), diff --git a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts index 30ad3f399916b..68d096e046ed4 100644 --- a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts @@ -74,7 +74,7 @@ const createService = async (serverBasePath: string = '') => { const spacesServiceSetup = await spacesService.setup({ http: httpSetup, - elasticsearch: elasticsearchServiceMock.createSetupContract(), + elasticsearch: elasticsearchServiceMock.createSetup(), config$: Rx.of(spacesConfig), authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => new SpacesAuditLogger({}), diff --git a/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts index b8d0f910a42ea..f8ed58fa57551 100644 --- a/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts @@ -5,7 +5,7 @@ */ import { map, take } from 'rxjs/operators'; -import { Observable, Subscription, combineLatest } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { Legacy } from 'kibana'; import { Logger, KibanaRequest, CoreSetup } from '../../../../../src/core/server'; import { PluginSetupContract as SecurityPluginSetup } from '../../../security/server'; @@ -69,15 +69,15 @@ export class SpacesService { }; const getScopedClient = async (request: KibanaRequest) => { - return combineLatest(elasticsearch.adminClient$, config$) + return config$ .pipe( - map(([clusterClient, config]) => { + map(config => { const internalRepository = this.getLegacyAPI().savedObjects.getSavedObjectsRepository( - clusterClient.callAsInternalUser, + elasticsearch.adminClient.callAsInternalUser, ['space'] ); - const callCluster = clusterClient.asScoped(request).callAsCurrentUser; + const callCluster = elasticsearch.adminClient.asScoped(request).callAsCurrentUser; const callWithRequestRepository = this.getLegacyAPI().savedObjects.getSavedObjectsRepository( callCluster, From 007ea5fc46d940d63891bc20ecff36db96ae1450 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Wed, 8 Jan 2020 12:18:55 +0300 Subject: [PATCH 02/63] set AppArch team as an owner of the search endpoints (#54131) --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1137fb99f81a7..18d60bce4b95e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -30,6 +30,7 @@ /src/plugins/visualizations/ @elastic/kibana-app-arch /x-pack/plugins/advanced_ui_actions/ @elastic/kibana-app-arch /src/legacy/core_plugins/data/ @elastic/kibana-app-arch +/src/legacy/core_plugins/elasticsearch/lib/create_proxy.js @elastic/kibana-app-arch /src/legacy/core_plugins/embeddable_api/ @elastic/kibana-app-arch /src/legacy/core_plugins/interpreter/ @elastic/kibana-app-arch /src/legacy/core_plugins/kibana_react/ @elastic/kibana-app-arch From 71ff2de7e163d5b9a1c20174a208d47136b422a2 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Wed, 8 Jan 2020 13:15:54 +0300 Subject: [PATCH 03/63] [ui/public/utils] Copy rarely used items to where they are consumed (#53819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ui/public/utils] Copy rarely used items to where they are consumed Closes: #52841 * sort_prefix_first 👉x-pack/legacy/plugins/kuery_autocomplete * numeric 👉src/legacy/core_plugins/kibana/public/management * diff_object + tests 👉ui/state_management * function + tests 👉ui/state_management (function.js was removed!) * key_map 👉ui/directives * leastCommonMultiple 👉ui/vis * string_utils 👉ui/saved_objects * collection * parse_interval * it -> test * fix CI * fix PR comments Co-authored-by: Elastic Machine --- .../management/sections/objects/_view.js | 2 +- .../objects/lib/case_conversion.test.ts} | 25 ++- .../sections/objects/lib/case_conversion.ts} | 6 +- .../sections/objects/lib/find_objects.js | 3 +- .../sections/objects/lib}/numeric.ts | 4 +- .../settings/lib/get_category_name.js | 5 +- .../tile_map/public/css_filters.js} | 33 ++- .../tile_map/public/tile_map_type.js | 4 +- .../public/directives/saved_object_finder.js | 2 +- .../public/lib/validate_interval.js | 2 +- .../server/status/lib/case_conversion.test.ts | 36 ++++ .../status/lib}/case_conversion.ts | 12 +- src/legacy/server/status/lib/metrics.js | 2 +- .../public/{utils => directives}/key_map.ts | 0 .../directives/watch_multi/watch_multi.js | 5 +- .../indexed_array/helpers/organize_by.test.ts | 63 ++++++ .../indexed_array/helpers/organize_by.ts | 61 ++++++ .../ui/public/indexed_array/indexed_array.js | 2 +- .../helpers/string_utils.test.ts} | 22 +- .../helpers}/string_utils.ts | 2 +- .../saved_objects/saved_object_loader.ts | 2 +- .../ui/public/state_management/app_state.js | 4 +- .../ui/public/state_management/state.js | 11 +- .../utils/diff_object.test.ts} | 70 +++--- .../state_management/utils/diff_object.ts | 75 +++++++ .../ui/public/time_buckets/time_buckets.js | 3 +- .../ui/public/utils/__tests__/collection.js | 200 ------------------ .../public/utils/__tests__/parse_interval.js | 93 -------- .../utils/__tests__/sort_prefix_first.js | 87 -------- src/legacy/ui/public/utils/collection.test.ts | 141 ++++++++++++ src/legacy/ui/public/utils/collection.ts | 73 +------ src/legacy/ui/public/utils/diff_object.js | 67 ------ src/legacy/ui/public/utils/math.test.ts | 66 ------ .../editors/config/editor_config_providers.ts | 2 +- .../public/vis/lib/least_common_interval.ts | 2 +- .../lib/least_common_multiple.test.ts} | 34 +-- .../lib/least_common_multiple.ts} | 6 +- src/legacy/utils/case_conversion.test.ts | 56 ----- src/plugins/data/common/utils/index.ts | 1 + .../data/common/utils/parse_interval.test.ts | 119 +++++++++++ .../data/common/utils/parse_interval.ts} | 15 +- src/plugins/data/public/index.ts | 2 + src/plugins/data/server/index.ts | 2 + src/test_utils/public/simulate_keys.js | 2 +- .../public/autocomplete_providers/field.js | 2 +- .../sort_prefix_first.test.ts | 79 +++++++ .../sort_prefix_first.ts | 20 ++ 47 files changed, 756 insertions(+), 769 deletions(-) rename src/legacy/{ui/public/utils/sort_prefix_first.ts => core_plugins/kibana/public/management/sections/objects/lib/case_conversion.test.ts} (62%) rename src/legacy/{ui/public/utils/parse_interval.d.ts => core_plugins/kibana/public/management/sections/objects/lib/case_conversion.ts} (82%) rename src/legacy/{ui/public/utils => core_plugins/kibana/public/management/sections/objects/lib}/numeric.ts (86%) rename src/legacy/{ui/public/utils/supports.js => core_plugins/tile_map/public/css_filters.js} (64%) create mode 100644 src/legacy/server/status/lib/case_conversion.test.ts rename src/legacy/{utils => server/status/lib}/case_conversion.ts (77%) rename src/legacy/ui/public/{utils => directives}/key_map.ts (100%) create mode 100644 src/legacy/ui/public/indexed_array/helpers/organize_by.test.ts create mode 100644 src/legacy/ui/public/indexed_array/helpers/organize_by.ts rename src/legacy/ui/public/{utils/function.js => saved_objects/helpers/string_utils.test.ts} (62%) rename src/legacy/ui/public/{utils => saved_objects/helpers}/string_utils.ts (94%) rename src/legacy/ui/public/{utils/__tests__/diff_object.js => state_management/utils/diff_object.test.ts} (57%) create mode 100644 src/legacy/ui/public/state_management/utils/diff_object.ts delete mode 100644 src/legacy/ui/public/utils/__tests__/collection.js delete mode 100644 src/legacy/ui/public/utils/__tests__/parse_interval.js delete mode 100644 src/legacy/ui/public/utils/__tests__/sort_prefix_first.js create mode 100644 src/legacy/ui/public/utils/collection.test.ts delete mode 100644 src/legacy/ui/public/utils/diff_object.js delete mode 100644 src/legacy/ui/public/utils/math.test.ts rename src/legacy/ui/public/{utils/case_conversion.ts => vis/lib/least_common_multiple.test.ts} (60%) rename src/legacy/ui/public/{utils/math.ts => vis/lib/least_common_multiple.ts} (95%) delete mode 100644 src/legacy/utils/case_conversion.test.ts create mode 100644 src/plugins/data/common/utils/parse_interval.test.ts rename src/{legacy/ui/public/utils/parse_interval.js => plugins/data/common/utils/parse_interval.ts} (86%) create mode 100644 x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/sort_prefix_first.test.ts create mode 100644 x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/sort_prefix_first.ts diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js index 0909b6947895c..f7e654fd3c76d 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js @@ -29,7 +29,7 @@ import { uiModules } from 'ui/modules'; import { fatalError, toastNotifications } from 'ui/notify'; import 'ui/accessibility/kbn_ui_ace_keyboard_mode'; import { SavedObjectsClientProvider } from 'ui/saved_objects'; -import { isNumeric } from 'ui/utils/numeric'; +import { isNumeric } from './lib/numeric'; import { canViewInApp } from './lib/in_app_url'; import { castEsToKbnFieldTypeName } from '../../../../../../../plugins/data/public'; diff --git a/src/legacy/ui/public/utils/sort_prefix_first.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/case_conversion.test.ts similarity index 62% rename from src/legacy/ui/public/utils/sort_prefix_first.ts rename to src/legacy/core_plugins/kibana/public/management/sections/objects/lib/case_conversion.test.ts index 4d1a8d7f39866..bb749de8dcb71 100644 --- a/src/legacy/ui/public/utils/sort_prefix_first.ts +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/case_conversion.test.ts @@ -17,17 +17,20 @@ * under the License. */ -import { partition } from 'lodash'; +import { keysToCamelCaseShallow } from './case_conversion'; -export function sortPrefixFirst(array: any[], prefix?: string | number, property?: string): any[] { - if (!prefix) { - return array; - } - const lowerCasePrefix = ('' + prefix).toLowerCase(); +describe('keysToCamelCaseShallow', () => { + test("should convert all of an object's keys to camel case", () => { + const data = { + camelCase: 'camelCase', + 'kebab-case': 'kebabCase', + snake_case: 'snakeCase', + }; - const partitions = partition(array, entry => { - const value = ('' + (property ? entry[property] : entry)).toLowerCase(); - return value.startsWith(lowerCasePrefix); + const result = keysToCamelCaseShallow(data); + + expect(result.camelCase).toBe('camelCase'); + expect(result.kebabCase).toBe('kebabCase'); + expect(result.snakeCase).toBe('snakeCase'); }); - return [...partitions[0], ...partitions[1]]; -} +}); diff --git a/src/legacy/ui/public/utils/parse_interval.d.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/case_conversion.ts similarity index 82% rename from src/legacy/ui/public/utils/parse_interval.d.ts rename to src/legacy/core_plugins/kibana/public/management/sections/objects/lib/case_conversion.ts index 9d78b4ef6cddf..718530eb3b602 100644 --- a/src/legacy/ui/public/utils/parse_interval.d.ts +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/case_conversion.ts @@ -17,6 +17,8 @@ * under the License. */ -import moment from 'moment'; +import { mapKeys, camelCase } from 'lodash'; -export function parseInterval(interval: string): moment.Duration | null; +export function keysToCamelCaseShallow(object: Record) { + return mapKeys(object, (value, key) => camelCase(key)); +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/find_objects.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/find_objects.js index f982966d1e314..caf2b5f503440 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/find_objects.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/find_objects.js @@ -18,7 +18,7 @@ */ import { kfetch } from 'ui/kfetch'; -import { keysToCamelCaseShallow } from 'ui/utils/case_conversion'; +import { keysToCamelCaseShallow } from './case_conversion'; export async function findObjects(findOptions) { const response = await kfetch({ @@ -26,5 +26,6 @@ export async function findObjects(findOptions) { pathname: '/api/kibana/management/saved_objects/_find', query: findOptions, }); + return keysToCamelCaseShallow(response); } diff --git a/src/legacy/ui/public/utils/numeric.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/numeric.ts similarity index 86% rename from src/legacy/ui/public/utils/numeric.ts rename to src/legacy/core_plugins/kibana/public/management/sections/objects/lib/numeric.ts index 1342498cf5dc3..c7bc6c26a378f 100644 --- a/src/legacy/ui/public/utils/numeric.ts +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/numeric.ts @@ -17,8 +17,8 @@ * under the License. */ -import _ from 'lodash'; +import { isNaN } from 'lodash'; export function isNumeric(v: any): boolean { - return !_.isNaN(v) && (typeof v === 'number' || (!Array.isArray(v) && !_.isNaN(parseFloat(v)))); + return !isNaN(v) && (typeof v === 'number' || (!Array.isArray(v) && !isNaN(parseFloat(v)))); } diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/get_category_name.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/get_category_name.js index 0155b10f60218..c64b332e8ebee 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/get_category_name.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/get_category_name.js @@ -17,9 +17,10 @@ * under the License. */ -import { StringUtils } from 'ui/utils/string_utils'; import { i18n } from '@kbn/i18n'; +const upperFirst = (str = '') => str.replace(/^./, str => str.toUpperCase()); + const names = { general: i18n.translate('kbn.management.settings.categoryNames.generalLabel', { defaultMessage: 'General', @@ -51,5 +52,5 @@ const names = { }; export function getCategoryName(category) { - return category ? names[category] || StringUtils.upperFirst(category) : ''; + return category ? names[category] || upperFirst(category) : ''; } diff --git a/src/legacy/ui/public/utils/supports.js b/src/legacy/core_plugins/tile_map/public/css_filters.js similarity index 64% rename from src/legacy/ui/public/utils/supports.js rename to src/legacy/core_plugins/tile_map/public/css_filters.js index 19c5f34d97f36..63d6a358059b3 100644 --- a/src/legacy/ui/public/utils/supports.js +++ b/src/legacy/core_plugins/tile_map/public/css_filters.js @@ -22,22 +22,21 @@ import _ from 'lodash'; /** * just a place to put feature detection checks */ -export const supports = { - cssFilters: (function() { - const e = document.createElement('img'); - const rules = ['webkitFilter', 'mozFilter', 'msFilter', 'filter']; - const test = 'grayscale(1)'; - rules.forEach(function(rule) { - e.style[rule] = test; - }); +export const supportsCssFilters = (function() { + const e = document.createElement('img'); + const rules = ['webkitFilter', 'mozFilter', 'msFilter', 'filter']; + const test = 'grayscale(1)'; - document.body.appendChild(e); - const styles = window.getComputedStyle(e); - const can = _(styles) - .pick(rules) - .includes(test); - document.body.removeChild(e); + rules.forEach(function(rule) { + e.style[rule] = test; + }); - return can; - })(), -}; + document.body.appendChild(e); + const styles = window.getComputedStyle(e); + const can = _(styles) + .pick(rules) + .includes(test); + document.body.removeChild(e); + + return can; +})(); diff --git a/src/legacy/core_plugins/tile_map/public/tile_map_type.js b/src/legacy/core_plugins/tile_map/public/tile_map_type.js index f2354614ac41a..f2e6469e768e7 100644 --- a/src/legacy/core_plugins/tile_map/public/tile_map_type.js +++ b/src/legacy/core_plugins/tile_map/public/tile_map_type.js @@ -20,7 +20,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { supports } from 'ui/utils/supports'; import { Schemas } from 'ui/vis/editors/default/schemas'; import { colorSchemas } from 'ui/vislib/components/color/truncated_colormaps'; import { convertToGeoJson } from 'ui/vis/map/convert_to_geojson'; @@ -29,6 +28,7 @@ import { createTileMapVisualization } from './tile_map_visualization'; import { Status } from '../../visualizations/public'; import { TileMapOptions } from './components/tile_map_options'; import { MapTypes } from './map_types'; +import { supportsCssFilters } from './css_filters'; export function createTileMapTypeDefinition(dependencies) { const CoordinateMapsVisualization = createTileMapVisualization(dependencies); @@ -44,7 +44,7 @@ export function createTileMapTypeDefinition(dependencies) { defaultMessage: 'Plot latitude and longitude coordinates on a map', }), visConfig: { - canDesaturate: !!supports.cssFilters, + canDesaturate: Boolean(supportsCssFilters), defaults: { colorSchema: 'Yellow to Red', mapType: 'Scaled Circle Markers', diff --git a/src/legacy/core_plugins/timelion/public/directives/saved_object_finder.js b/src/legacy/core_plugins/timelion/public/directives/saved_object_finder.js index c0092ca49c4f3..111db0a83ffc4 100644 --- a/src/legacy/core_plugins/timelion/public/directives/saved_object_finder.js +++ b/src/legacy/core_plugins/timelion/public/directives/saved_object_finder.js @@ -19,12 +19,12 @@ import _ from 'lodash'; import rison from 'rison-node'; -import { keyMap } from 'ui/utils/key_map'; import { uiModules } from 'ui/modules'; import 'ui/directives/input_focus'; import 'ui/directives/paginate'; import savedObjectFinderTemplate from './saved_object_finder.html'; import { savedSheetLoader } from '../services/saved_sheets'; +import { keyMap } from 'ui/directives/key_map'; const module = uiModules.get('kibana'); diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/lib/validate_interval.js b/src/legacy/core_plugins/vis_type_timeseries/public/lib/validate_interval.js index 46dab92f3b245..2dbcdc4749a66 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/lib/validate_interval.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/lib/validate_interval.js @@ -17,9 +17,9 @@ * under the License. */ -import { parseInterval } from 'ui/utils/parse_interval'; import { GTE_INTERVAL_RE } from '../../common/interval_regexp'; import { i18n } from '@kbn/i18n'; +import { parseInterval } from '../../../../../plugins/data/public'; export function validateInterval(bounds, panel, maxBuckets) { const { interval } = panel; diff --git a/src/legacy/server/status/lib/case_conversion.test.ts b/src/legacy/server/status/lib/case_conversion.test.ts new file mode 100644 index 0000000000000..a231ee0ba4b0f --- /dev/null +++ b/src/legacy/server/status/lib/case_conversion.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { keysToSnakeCaseShallow } from './case_conversion'; + +describe('keysToSnakeCaseShallow', () => { + test("should convert all of an object's keys to snake case", () => { + const data = { + camelCase: 'camel_case', + 'kebab-case': 'kebab_case', + snake_case: 'snake_case', + }; + + const result = keysToSnakeCaseShallow(data); + + expect(result.camel_case).toBe('camel_case'); + expect(result.kebab_case).toBe('kebab_case'); + expect(result.snake_case).toBe('snake_case'); + }); +}); diff --git a/src/legacy/utils/case_conversion.ts b/src/legacy/server/status/lib/case_conversion.ts similarity index 77% rename from src/legacy/utils/case_conversion.ts rename to src/legacy/server/status/lib/case_conversion.ts index e1c1317b26b14..a3ae15028daeb 100644 --- a/src/legacy/utils/case_conversion.ts +++ b/src/legacy/server/status/lib/case_conversion.ts @@ -17,16 +17,8 @@ * under the License. */ -import _ from 'lodash'; +import { mapKeys, snakeCase } from 'lodash'; export function keysToSnakeCaseShallow(object: Record) { - return _.mapKeys(object, (value, key) => { - return _.snakeCase(key); - }); -} - -export function keysToCamelCaseShallow(object: Record) { - return _.mapKeys(object, (value, key) => { - return _.camelCase(key); - }); + return mapKeys(object, (value, key) => snakeCase(key)); } diff --git a/src/legacy/server/status/lib/metrics.js b/src/legacy/server/status/lib/metrics.js index 322d8d8bd9ab4..2631b245e72ab 100644 --- a/src/legacy/server/status/lib/metrics.js +++ b/src/legacy/server/status/lib/metrics.js @@ -20,7 +20,7 @@ import os from 'os'; import v8 from 'v8'; import { get, isObject, merge } from 'lodash'; -import { keysToSnakeCaseShallow } from '../../../utils/case_conversion'; +import { keysToSnakeCaseShallow } from './case_conversion'; import { getAllStats as cGroupStats } from './cgroup'; import { getOSInfo } from './get_os_info'; diff --git a/src/legacy/ui/public/utils/key_map.ts b/src/legacy/ui/public/directives/key_map.ts similarity index 100% rename from src/legacy/ui/public/utils/key_map.ts rename to src/legacy/ui/public/directives/key_map.ts diff --git a/src/legacy/ui/public/directives/watch_multi/watch_multi.js b/src/legacy/ui/public/directives/watch_multi/watch_multi.js index 2a2ffe24cba9c..54b5cf08a9397 100644 --- a/src/legacy/ui/public/directives/watch_multi/watch_multi.js +++ b/src/legacy/ui/public/directives/watch_multi/watch_multi.js @@ -19,7 +19,6 @@ import _ from 'lodash'; import { uiModules } from '../../modules'; -import { callEach } from '../../utils/function'; export function watchMultiDecorator($provide) { $provide.decorator('$rootScope', function($delegate) { @@ -112,7 +111,9 @@ export function watchMultiDecorator($provide) { ) ); - return _.partial(callEach, unwatchers); + return function() { + unwatchers.forEach(listener => listener()); + }; }; function normalizeExpression($scope, expr) { diff --git a/src/legacy/ui/public/indexed_array/helpers/organize_by.test.ts b/src/legacy/ui/public/indexed_array/helpers/organize_by.test.ts new file mode 100644 index 0000000000000..fc4ca8469382a --- /dev/null +++ b/src/legacy/ui/public/indexed_array/helpers/organize_by.test.ts @@ -0,0 +1,63 @@ +/* + * 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 { groupBy } from 'lodash'; +import { organizeBy } from './organize_by'; + +describe('organizeBy', () => { + test('it works', () => { + const col = [ + { + name: 'one', + roles: ['user', 'admin', 'owner'], + }, + { + name: 'two', + roles: ['user'], + }, + { + name: 'three', + roles: ['user'], + }, + { + name: 'four', + roles: ['user', 'admin'], + }, + ]; + + const resp = organizeBy(col, 'roles'); + expect(resp).toHaveProperty('user'); + expect(resp.user.length).toBe(4); + + expect(resp).toHaveProperty('admin'); + expect(resp.admin.length).toBe(2); + + expect(resp).toHaveProperty('owner'); + expect(resp.owner.length).toBe(1); + }); + + test('behaves just like groupBy in normal scenarios', () => { + const col = [{ name: 'one' }, { name: 'two' }, { name: 'three' }, { name: 'four' }]; + + const orgs = organizeBy(col, 'name'); + const groups = groupBy(col, 'name'); + + expect(orgs).toEqual(groups); + }); +}); diff --git a/src/legacy/ui/public/indexed_array/helpers/organize_by.ts b/src/legacy/ui/public/indexed_array/helpers/organize_by.ts new file mode 100644 index 0000000000000..e923767c892cd --- /dev/null +++ b/src/legacy/ui/public/indexed_array/helpers/organize_by.ts @@ -0,0 +1,61 @@ +/* + * 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 { each, isFunction } from 'lodash'; + +/** + * Like _.groupBy, but allows specifying multiple groups for a + * single object. + * + * organizeBy([{ a: [1, 2, 3] }, { b: true, a: [1, 4] }], 'a') + * // Object {1: Array[2], 2: Array[1], 3: Array[1], 4: Array[1]} + * + * _.groupBy([{ a: [1, 2, 3] }, { b: true, a: [1, 4] }], 'a') + * // Object {'1,2,3': Array[1], '1,4': Array[1]} + * + * @param {array} collection - the list of values to organize + * @param {Function} callback - either a property name, or a callback. + * @return {object} + */ +export function organizeBy(collection: object[], callback: ((obj: object) => string) | string) { + const buckets: { [key: string]: object[] } = {}; + + function add(key: string, obj: object) { + if (!buckets[key]) { + buckets[key] = []; + } + buckets[key].push(obj); + } + + each(collection, (obj: Record) => { + const keys = isFunction(callback) ? callback(obj) : obj[callback]; + + if (!Array.isArray(keys)) { + add(keys, obj); + return; + } + + let length = keys.length; + while (length-- > 0) { + add(keys[length], obj); + } + }); + + return buckets; +} diff --git a/src/legacy/ui/public/indexed_array/indexed_array.js b/src/legacy/ui/public/indexed_array/indexed_array.js index 96b37a1423be0..39c79b2f021a3 100644 --- a/src/legacy/ui/public/indexed_array/indexed_array.js +++ b/src/legacy/ui/public/indexed_array/indexed_array.js @@ -19,7 +19,7 @@ import _ from 'lodash'; import { inflector } from './inflector'; -import { organizeBy } from '../utils/collection'; +import { organizeBy } from './helpers/organize_by'; const pathGetter = _(_.get) .rearg(1, 0) diff --git a/src/legacy/ui/public/utils/function.js b/src/legacy/ui/public/saved_objects/helpers/string_utils.test.ts similarity index 62% rename from src/legacy/ui/public/utils/function.js rename to src/legacy/ui/public/saved_objects/helpers/string_utils.test.ts index f9e7777d08bfe..5b108a4cc0180 100644 --- a/src/legacy/ui/public/utils/function.js +++ b/src/legacy/ui/public/saved_objects/helpers/string_utils.test.ts @@ -16,17 +16,17 @@ * specific language governing permissions and limitations * under the License. */ +import { StringUtils } from './string_utils'; -import _ from 'lodash'; +describe('StringUtils class', () => { + describe('static upperFirst', () => { + test('should converts the first character of string to upper case', () => { + expect(StringUtils.upperFirst()).toBe(''); + expect(StringUtils.upperFirst('')).toBe(''); -/** - * Call all of the function in an array - * - * @param {array[functions]} arr - * @return {undefined} - */ -export function callEach(arr) { - return _.map(arr, function(fn) { - return _.isFunction(fn) ? fn() : undefined; + expect(StringUtils.upperFirst('Fred')).toBe('Fred'); + expect(StringUtils.upperFirst('fred')).toBe('Fred'); + expect(StringUtils.upperFirst('FRED')).toBe('FRED'); + }); }); -} +}); diff --git a/src/legacy/ui/public/utils/string_utils.ts b/src/legacy/ui/public/saved_objects/helpers/string_utils.ts similarity index 94% rename from src/legacy/ui/public/utils/string_utils.ts rename to src/legacy/ui/public/saved_objects/helpers/string_utils.ts index 22a57aeb07933..fb10b792b7e69 100644 --- a/src/legacy/ui/public/utils/string_utils.ts +++ b/src/legacy/ui/public/saved_objects/helpers/string_utils.ts @@ -23,7 +23,7 @@ export class StringUtils { * @param str {string} * @returns {string} */ - public static upperFirst(str: string): string { + public static upperFirst(str: string = ''): string { return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; } } diff --git a/src/legacy/ui/public/saved_objects/saved_object_loader.ts b/src/legacy/ui/public/saved_objects/saved_object_loader.ts index eb880ce5380c0..7784edc9ad528 100644 --- a/src/legacy/ui/public/saved_objects/saved_object_loader.ts +++ b/src/legacy/ui/public/saved_objects/saved_object_loader.ts @@ -18,7 +18,7 @@ */ import { SavedObject } from 'ui/saved_objects/types'; import { ChromeStart, SavedObjectsClientContract, SavedObjectsFindOptions } from 'kibana/public'; -import { StringUtils } from '../utils/string_utils'; +import { StringUtils } from './helpers/string_utils'; /** * The SavedObjectLoader class provides some convenience functions diff --git a/src/legacy/ui/public/state_management/app_state.js b/src/legacy/ui/public/state_management/app_state.js index 4bf386febf836..e253d49c04131 100644 --- a/src/legacy/ui/public/state_management/app_state.js +++ b/src/legacy/ui/public/state_management/app_state.js @@ -31,7 +31,6 @@ import { uiModules } from '../modules'; import { StateProvider } from './state'; import '../persisted_state'; import { createLegacyClass } from '../utils/legacy_class'; -import { callEach } from '../utils/function'; const urlParam = '_a'; @@ -62,7 +61,8 @@ export function AppStateProvider(Private, $location, $injector) { AppState.prototype.destroy = function() { AppState.Super.prototype.destroy.call(this); AppState.getAppState._set(null); - callEach(eventUnsubscribers); + + eventUnsubscribers.forEach(listener => listener()); }; /** diff --git a/src/legacy/ui/public/state_management/state.js b/src/legacy/ui/public/state_management/state.js index a9898303fa5be..289d4b8006cba 100644 --- a/src/legacy/ui/public/state_management/state.js +++ b/src/legacy/ui/public/state_management/state.js @@ -29,12 +29,11 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import angular from 'angular'; import rison from 'rison-node'; -import { applyDiff } from '../utils/diff_object'; +import { applyDiff } from './utils/diff_object'; import { EventsProvider } from '../events'; import { fatalError, toastNotifications } from '../notify'; import './config_provider'; import { createLegacyClass } from '../utils/legacy_class'; -import { callEach } from '../utils/function'; import { hashedItemStore, isStateHash, @@ -66,7 +65,7 @@ export function StateProvider( this._hashedItemStore = _hashedItemStore; // When the URL updates we need to fetch the values from the URL - this._cleanUpListeners = _.partial(callEach, [ + this._cleanUpListeners = [ // partial route update, no app reload $rootScope.$on('$routeUpdate', () => { this.fetch(); @@ -85,7 +84,7 @@ export function StateProvider( this.fetch(); } }), - ]); + ]; // Initialize the State with fetch this.fetch(); @@ -242,7 +241,9 @@ export function StateProvider( */ State.prototype.destroy = function() { this.off(); // removes all listeners - this._cleanUpListeners(); // Removes the $routeUpdate listener + + // Removes the $routeUpdate listener + this._cleanUpListeners.forEach(listener => listener(this)); }; State.prototype.setDefaults = function(defaults) { diff --git a/src/legacy/ui/public/utils/__tests__/diff_object.js b/src/legacy/ui/public/state_management/utils/diff_object.test.ts similarity index 57% rename from src/legacy/ui/public/utils/__tests__/diff_object.js rename to src/legacy/ui/public/state_management/utils/diff_object.test.ts index 8459aa60436b1..d93fa0fe5a169 100644 --- a/src/legacy/ui/public/utils/__tests__/diff_object.js +++ b/src/legacy/ui/public/state_management/utils/diff_object.test.ts @@ -17,76 +17,88 @@ * under the License. */ -import expect from '@kbn/expect'; -import _ from 'lodash'; -import { applyDiff } from '../diff_object'; +import { cloneDeep } from 'lodash'; +import { applyDiff } from './diff_object'; -describe('ui/utils/diff_object', function() { - it('should list the removed keys', function() { +describe('diff_object', () => { + test('should list the removed keys', () => { const target = { test: 'foo' }; const source = { foo: 'test' }; const results = applyDiff(target, source); - expect(results).to.have.property('removed'); - expect(results.removed).to.eql(['test']); + + expect(results).toHaveProperty('removed'); + expect(results.removed).toEqual(['test']); }); - it('should list the changed keys', function() { + test('should list the changed keys', () => { const target = { foo: 'bar' }; const source = { foo: 'test' }; const results = applyDiff(target, source); - expect(results).to.have.property('changed'); - expect(results.changed).to.eql(['foo']); + + expect(results).toHaveProperty('changed'); + expect(results.changed).toEqual(['foo']); }); - it('should list the added keys', function() { + test('should list the added keys', () => { const target = {}; const source = { foo: 'test' }; const results = applyDiff(target, source); - expect(results).to.have.property('added'); - expect(results.added).to.eql(['foo']); + + expect(results).toHaveProperty('added'); + expect(results.added).toEqual(['foo']); }); - it('should list all the keys that are change or removed', function() { + test('should list all the keys that are change or removed', () => { const target = { foo: 'bar', test: 'foo' }; const source = { foo: 'test' }; const results = applyDiff(target, source); - expect(results).to.have.property('keys'); - expect(results.keys).to.eql(['foo', 'test']); + + expect(results).toHaveProperty('keys'); + expect(results.keys).toEqual(['foo', 'test']); }); - it('should ignore functions', function() { + test('should ignore functions', () => { const target = { foo: 'bar', test: 'foo' }; - const source = { foo: 'test', fn: _.noop }; + const source = { foo: 'test', fn: () => {} }; + applyDiff(target, source); - expect(target).to.not.have.property('fn'); + + expect(target).not.toHaveProperty('fn'); }); - it('should ignore underscores', function() { + test('should ignore underscores', () => { const target = { foo: 'bar', test: 'foo' }; const source = { foo: 'test', _private: 'foo' }; + applyDiff(target, source); - expect(target).to.not.have.property('_private'); + + expect(target).not.toHaveProperty('_private'); }); - it('should ignore dollar signs', function() { + test('should ignore dollar signs', () => { const target = { foo: 'bar', test: 'foo' }; const source = { foo: 'test', $private: 'foo' }; + applyDiff(target, source); - expect(target).to.not.have.property('$private'); + + expect(target).not.toHaveProperty('$private'); }); - it('should not list any changes for similar objects', function() { + test('should not list any changes for similar objects', () => { const target = { foo: 'bar', test: 'foo' }; const source = { foo: 'bar', test: 'foo', $private: 'foo' }; const results = applyDiff(target, source); - expect(results.changed).to.be.empty(); + + expect(results.changed).toEqual([]); }); - it('should only change keys that actually changed', function() { + test('should only change keys that actually changed', () => { const obj = { message: 'foo' }; - const target = { obj: obj, message: 'foo' }; - const source = { obj: _.cloneDeep(obj), message: 'test' }; + const target = { obj, message: 'foo' }; + const source = { obj: cloneDeep(obj), message: 'test' }; + applyDiff(target, source); - expect(target.obj).to.be(obj); + + expect(target.obj).toBe(obj); }); }); diff --git a/src/legacy/ui/public/state_management/utils/diff_object.ts b/src/legacy/ui/public/state_management/utils/diff_object.ts new file mode 100644 index 0000000000000..2590e2271f771 --- /dev/null +++ b/src/legacy/ui/public/state_management/utils/diff_object.ts @@ -0,0 +1,75 @@ +/* + * 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 { keys, isFunction, difference, filter, union, pick, each, assign, isEqual } from 'lodash'; + +export interface IDiffObject { + removed: string[]; + added: string[]; + changed: string[]; + keys: string[]; +} + +/** + * Filter the private vars + * @param {string} key The keys + * @returns {boolean} + */ +const filterPrivateAndMethods = function(obj: Record) { + return function(key: string) { + if (isFunction(obj[key])) return false; + if (key.charAt(0) === '$') return false; + return key.charAt(0) !== '_'; + }; +}; + +export function applyDiff(target: Record, source: Record) { + const diff: IDiffObject = { + removed: [], + added: [], + changed: [], + keys: [], + }; + + const targetKeys = keys(target).filter(filterPrivateAndMethods(target)); + const sourceKeys = keys(source).filter(filterPrivateAndMethods(source)); + + // Find the keys to be removed + diff.removed = difference(targetKeys, sourceKeys); + + // Find the keys to be added + diff.added = difference(sourceKeys, targetKeys); + + // Find the keys that will be changed + diff.changed = filter(sourceKeys, key => !isEqual(target[key], source[key])); + + // Make a list of all the keys that are changing + diff.keys = union(diff.changed, diff.removed, diff.added); + + // Remove all the keys + each(diff.removed, key => { + delete target[key]; + }); + + // Assign the changed to the source to the target + assign(target, pick(source, diff.changed)); + // Assign the added to the source to the target + assign(target, pick(source, diff.added)); + + return diff; +} diff --git a/src/legacy/ui/public/time_buckets/time_buckets.js b/src/legacy/ui/public/time_buckets/time_buckets.js index 92de88b47e3c3..96ba4bfb2ac2c 100644 --- a/src/legacy/ui/public/time_buckets/time_buckets.js +++ b/src/legacy/ui/public/time_buckets/time_buckets.js @@ -20,13 +20,12 @@ import _ from 'lodash'; import moment from 'moment'; import { npStart } from 'ui/new_platform'; -import { parseInterval } from '../utils/parse_interval'; import { calcAutoIntervalLessThan, calcAutoIntervalNear } from './calc_auto_interval'; import { convertDurationToNormalizedEsInterval, convertIntervalToEsInterval, } from './calc_es_interval'; -import { FIELD_FORMAT_IDS } from '../../../../plugins/data/public'; +import { FIELD_FORMAT_IDS, parseInterval } from '../../../../plugins/data/public'; const getConfig = (...args) => npStart.core.uiSettings.get(...args); diff --git a/src/legacy/ui/public/utils/__tests__/collection.js b/src/legacy/ui/public/utils/__tests__/collection.js deleted file mode 100644 index 402f0387e53ce..0000000000000 --- a/src/legacy/ui/public/utils/__tests__/collection.js +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; -import { groupBy } from 'lodash'; -import { move, pushAll, organizeBy } from '../collection'; - -describe('collection', () => { - describe('move', function() { - it('accepts previous from->to syntax', function() { - const list = [1, 1, 1, 1, 1, 1, 1, 1, 8, 1, 1]; - - expect(list[3]).to.be(1); - expect(list[8]).to.be(8); - - move(list, 8, 3); - - expect(list[8]).to.be(1); - expect(list[3]).to.be(8); - }); - - it('moves an object up based on a function callback', function() { - const list = [1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1]; - - expect(list[4]).to.be(0); - expect(list[5]).to.be(1); - expect(list[6]).to.be(0); - - move(list, 5, false, function(v) { - return v === 0; - }); - - expect(list[4]).to.be(1); - expect(list[5]).to.be(0); - expect(list[6]).to.be(0); - }); - - it('moves an object down based on a function callback', function() { - const list = [1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1]; - - expect(list[4]).to.be(0); - expect(list[5]).to.be(1); - expect(list[6]).to.be(0); - - move(list, 5, true, function(v) { - return v === 0; - }); - - expect(list[4]).to.be(0); - expect(list[5]).to.be(0); - expect(list[6]).to.be(1); - }); - - it('moves an object up based on a where callback', function() { - const list = [ - { v: 1 }, - { v: 1 }, - { v: 1 }, - { v: 1 }, - { v: 0 }, - { v: 1 }, - { v: 0 }, - { v: 1 }, - { v: 1 }, - { v: 1 }, - { v: 1 }, - ]; - - expect(list[4]).to.have.property('v', 0); - expect(list[5]).to.have.property('v', 1); - expect(list[6]).to.have.property('v', 0); - - move(list, 5, false, { v: 0 }); - - expect(list[4]).to.have.property('v', 1); - expect(list[5]).to.have.property('v', 0); - expect(list[6]).to.have.property('v', 0); - }); - - it('moves an object down based on a where callback', function() { - const list = [ - { v: 1 }, - { v: 1 }, - { v: 1 }, - { v: 1 }, - { v: 0 }, - { v: 1 }, - { v: 0 }, - { v: 1 }, - { v: 1 }, - { v: 1 }, - { v: 1 }, - ]; - - expect(list[4]).to.have.property('v', 0); - expect(list[5]).to.have.property('v', 1); - expect(list[6]).to.have.property('v', 0); - - move(list, 5, true, { v: 0 }); - - expect(list[4]).to.have.property('v', 0); - expect(list[5]).to.have.property('v', 0); - expect(list[6]).to.have.property('v', 1); - }); - - it('moves an object down based on a pluck callback', function() { - const list = [ - { id: 0, normal: true }, - { id: 1, normal: true }, - { id: 2, normal: true }, - { id: 3, normal: true }, - { id: 4, normal: true }, - { id: 5, normal: false }, - { id: 6, normal: true }, - { id: 7, normal: true }, - { id: 8, normal: true }, - { id: 9, normal: true }, - ]; - - expect(list[4]).to.have.property('id', 4); - expect(list[5]).to.have.property('id', 5); - expect(list[6]).to.have.property('id', 6); - - move(list, 5, true, 'normal'); - - expect(list[4]).to.have.property('id', 4); - expect(list[5]).to.have.property('id', 6); - expect(list[6]).to.have.property('id', 5); - }); - }); - - describe('pushAll', function() { - it('pushes an entire array into another', function() { - const a = [1, 2, 3, 4]; - const b = [5, 6, 7, 8]; - - const output = pushAll(b, a); - expect(output).to.be(a); - expect(a).to.eql([1, 2, 3, 4, 5, 6, 7, 8]); - expect(b).to.eql([5, 6, 7, 8]); - }); - }); - - describe('organizeBy', function() { - it('it works', function() { - const col = [ - { - name: 'one', - roles: ['user', 'admin', 'owner'], - }, - { - name: 'two', - roles: ['user'], - }, - { - name: 'three', - roles: ['user'], - }, - { - name: 'four', - roles: ['user', 'admin'], - }, - ]; - - const resp = organizeBy(col, 'roles'); - expect(resp).to.have.property('user'); - expect(resp.user).to.have.length(4); - - expect(resp).to.have.property('admin'); - expect(resp.admin).to.have.length(2); - - expect(resp).to.have.property('owner'); - expect(resp.owner).to.have.length(1); - }); - - it('behaves just like groupBy in normal scenarios', function() { - const col = [{ name: 'one' }, { name: 'two' }, { name: 'three' }, { name: 'four' }]; - - const orgs = organizeBy(col, 'name'); - const groups = groupBy(col, 'name'); - expect(orgs).to.eql(groups); - }); - }); -}); diff --git a/src/legacy/ui/public/utils/__tests__/parse_interval.js b/src/legacy/ui/public/utils/__tests__/parse_interval.js deleted file mode 100644 index a33b0ab958072..0000000000000 --- a/src/legacy/ui/public/utils/__tests__/parse_interval.js +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { parseInterval } from '../parse_interval'; -import expect from '@kbn/expect'; - -describe('parseInterval', function() { - it('should correctly parse an interval containing unit and value', function() { - let duration = parseInterval('1d'); - expect(duration.as('d')).to.be(1); - - duration = parseInterval('2y'); - expect(duration.as('y')).to.be(2); - - duration = parseInterval('5M'); - expect(duration.as('M')).to.be(5); - - duration = parseInterval('5m'); - expect(duration.as('m')).to.be(5); - - duration = parseInterval('250ms'); - expect(duration.as('ms')).to.be(250); - - duration = parseInterval('100s'); - expect(duration.as('s')).to.be(100); - - duration = parseInterval('23d'); - expect(duration.as('d')).to.be(23); - - duration = parseInterval('52w'); - expect(duration.as('w')).to.be(52); - }); - - it('should correctly parse fractional intervals containing unit and value', function() { - let duration = parseInterval('1.5w'); - expect(duration.as('w')).to.be(1.5); - - duration = parseInterval('2.35y'); - expect(duration.as('y')).to.be(2.35); - }); - - it('should correctly bubble up intervals which are less than 1', function() { - let duration = parseInterval('0.5y'); - expect(duration.as('d')).to.be(183); - - duration = parseInterval('0.5d'); - expect(duration.as('h')).to.be(12); - }); - - it('should correctly parse a unit in an interval only', function() { - let duration = parseInterval('ms'); - expect(duration.as('ms')).to.be(1); - - duration = parseInterval('d'); - expect(duration.as('d')).to.be(1); - - duration = parseInterval('m'); - expect(duration.as('m')).to.be(1); - - duration = parseInterval('y'); - expect(duration.as('y')).to.be(1); - - duration = parseInterval('M'); - expect(duration.as('M')).to.be(1); - }); - - it('should return null for an invalid interval', function() { - let duration = parseInterval(''); - expect(duration).to.not.be.ok(); - - duration = parseInterval(null); - expect(duration).to.not.be.ok(); - - duration = parseInterval('234asdf'); - expect(duration).to.not.be.ok(); - }); -}); diff --git a/src/legacy/ui/public/utils/__tests__/sort_prefix_first.js b/src/legacy/ui/public/utils/__tests__/sort_prefix_first.js deleted file mode 100644 index 721de95cbd27d..0000000000000 --- a/src/legacy/ui/public/utils/__tests__/sort_prefix_first.js +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; -import { sortPrefixFirst } from '../sort_prefix_first'; - -describe('sortPrefixFirst', function() { - it('should return the original unmodified array if no prefix is provided', function() { - const array = ['foo', 'bar', 'baz']; - const result = sortPrefixFirst(array); - expect(result).to.be(array); - expect(result).to.eql(['foo', 'bar', 'baz']); - }); - - it('should sort items that match the prefix first without modifying the original array', function() { - const array = ['foo', 'bar', 'baz']; - const result = sortPrefixFirst(array, 'b'); - expect(result).to.not.be(array); - expect(result).to.eql(['bar', 'baz', 'foo']); - expect(array).to.eql(['foo', 'bar', 'baz']); - }); - - it('should not modify the order of the array other than matching prefix without modifying the original array', function() { - const array = ['foo', 'bar', 'baz', 'qux', 'quux']; - const result = sortPrefixFirst(array, 'b'); - expect(result).to.not.be(array); - expect(result).to.eql(['bar', 'baz', 'foo', 'qux', 'quux']); - expect(array).to.eql(['foo', 'bar', 'baz', 'qux', 'quux']); - }); - - it('should sort objects by property if provided', function() { - const array = [ - { name: 'foo' }, - { name: 'bar' }, - { name: 'baz' }, - { name: 'qux' }, - { name: 'quux' }, - ]; - const result = sortPrefixFirst(array, 'b', 'name'); - expect(result).to.not.be(array); - expect(result).to.eql([ - { name: 'bar' }, - { name: 'baz' }, - { name: 'foo' }, - { name: 'qux' }, - { name: 'quux' }, - ]); - expect(array).to.eql([ - { name: 'foo' }, - { name: 'bar' }, - { name: 'baz' }, - { name: 'qux' }, - { name: 'quux' }, - ]); - }); - - it('should handle numbers', function() { - const array = [1, 50, 5]; - const result = sortPrefixFirst(array, 5); - expect(result).to.not.be(array); - expect(result).to.eql([50, 5, 1]); - }); - - it('should handle mixed case', function() { - const array = ['Date Histogram', 'Histogram']; - const prefix = 'histo'; - const result = sortPrefixFirst(array, prefix); - expect(result).to.not.be(array); - expect(result).to.eql(['Histogram', 'Date Histogram']); - }); -}); diff --git a/src/legacy/ui/public/utils/collection.test.ts b/src/legacy/ui/public/utils/collection.test.ts new file mode 100644 index 0000000000000..0841e3554c0d0 --- /dev/null +++ b/src/legacy/ui/public/utils/collection.test.ts @@ -0,0 +1,141 @@ +/* + * 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 { move } from './collection'; + +describe('collection', () => { + describe('move', () => { + test('accepts previous from->to syntax', () => { + const list = [1, 1, 1, 1, 1, 1, 1, 1, 8, 1, 1]; + + expect(list[3]).toBe(1); + expect(list[8]).toBe(8); + + move(list, 8, 3); + + expect(list[8]).toBe(1); + expect(list[3]).toBe(8); + }); + + test('moves an object up based on a function callback', () => { + const list = [1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1]; + + expect(list[4]).toBe(0); + expect(list[5]).toBe(1); + expect(list[6]).toBe(0); + + move(list, 5, false, (v: any) => v === 0); + + expect(list[4]).toBe(1); + expect(list[5]).toBe(0); + expect(list[6]).toBe(0); + }); + + test('moves an object down based on a function callback', () => { + const list = [1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1]; + + expect(list[4]).toBe(0); + expect(list[5]).toBe(1); + expect(list[6]).toBe(0); + + move(list, 5, true, (v: any) => v === 0); + + expect(list[4]).toBe(0); + expect(list[5]).toBe(0); + expect(list[6]).toBe(1); + }); + + test('moves an object up based on a where callback', () => { + const list = [ + { v: 1 }, + { v: 1 }, + { v: 1 }, + { v: 1 }, + { v: 0 }, + { v: 1 }, + { v: 0 }, + { v: 1 }, + { v: 1 }, + { v: 1 }, + { v: 1 }, + ]; + + expect(list[4]).toHaveProperty('v', 0); + expect(list[5]).toHaveProperty('v', 1); + expect(list[6]).toHaveProperty('v', 0); + + move(list, 5, false, { v: 0 }); + + expect(list[4]).toHaveProperty('v', 1); + expect(list[5]).toHaveProperty('v', 0); + expect(list[6]).toHaveProperty('v', 0); + }); + + test('moves an object down based on a where callback', () => { + const list = [ + { v: 1 }, + { v: 1 }, + { v: 1 }, + { v: 1 }, + { v: 0 }, + { v: 1 }, + { v: 0 }, + { v: 1 }, + { v: 1 }, + { v: 1 }, + { v: 1 }, + ]; + + expect(list[4]).toHaveProperty('v', 0); + expect(list[5]).toHaveProperty('v', 1); + expect(list[6]).toHaveProperty('v', 0); + + move(list, 5, true, { v: 0 }); + + expect(list[4]).toHaveProperty('v', 0); + expect(list[5]).toHaveProperty('v', 0); + expect(list[6]).toHaveProperty('v', 1); + }); + + test('moves an object down based on a pluck callback', () => { + const list = [ + { id: 0, normal: true }, + { id: 1, normal: true }, + { id: 2, normal: true }, + { id: 3, normal: true }, + { id: 4, normal: true }, + { id: 5, normal: false }, + { id: 6, normal: true }, + { id: 7, normal: true }, + { id: 8, normal: true }, + { id: 9, normal: true }, + ]; + + expect(list[4]).toHaveProperty('id', 4); + expect(list[5]).toHaveProperty('id', 5); + expect(list[6]).toHaveProperty('id', 6); + + move(list, 5, true, 'normal'); + + expect(list[4]).toHaveProperty('id', 4); + expect(list[5]).toHaveProperty('id', 6); + expect(list[6]).toHaveProperty('id', 5); + }); + }); +}); diff --git a/src/legacy/ui/public/utils/collection.ts b/src/legacy/ui/public/utils/collection.ts index 61a7388575c93..45e5a0704c37b 100644 --- a/src/legacy/ui/public/utils/collection.ts +++ b/src/legacy/ui/public/utils/collection.ts @@ -33,10 +33,10 @@ import _ from 'lodash'; * @return {array} - the objs argument */ export function move( - objs: object[], + objs: any[], obj: object | number, below: number | boolean, - qualifier: (object: object, index: number) => any + qualifier?: ((object: object, index: number) => any) | Record | string ): object[] { const origI = _.isNumber(obj) ? obj : objs.indexOf(obj); if (origI === -1) { @@ -50,7 +50,7 @@ export function move( } below = !!below; - qualifier = _.callback(qualifier); + qualifier = qualifier && _.callback(qualifier); const above = !below; const finder = below ? _.findIndex : _.findLastIndex; @@ -63,7 +63,7 @@ export function move( if (above && otherI >= origI) { return; } - return !!qualifier(otherAgg, otherI); + return Boolean(_.isFunction(qualifier) && qualifier(otherAgg, otherI)); }); if (targetI === -1) { @@ -74,68 +74,3 @@ export function move( objs.splice(targetI, 0, objs.splice(origI, 1)[0]); return objs; } - -/** - * Like _.groupBy, but allows specifying multiple groups for a - * single object. - * - * organizeBy([{ a: [1, 2, 3] }, { b: true, a: [1, 4] }], 'a') - * // Object {1: Array[2], 2: Array[1], 3: Array[1], 4: Array[1]} - * - * _.groupBy([{ a: [1, 2, 3] }, { b: true, a: [1, 4] }], 'a') - * // Object {'1,2,3': Array[1], '1,4': Array[1]} - * - * @param {array} collection - the list of values to organize - * @param {Function} callback - either a property name, or a callback. - * @return {object} - */ -export function organizeBy(collection: object[], callback: (obj: object) => string | string) { - const buckets: { [key: string]: object[] } = {}; - const prop = typeof callback === 'function' ? false : callback; - - function add(key: string, obj: object) { - if (!buckets[key]) { - buckets[key] = []; - } - buckets[key].push(obj); - } - - _.each(collection, (obj: object) => { - const keys = prop === false ? callback(obj) : obj[prop]; - - if (!Array.isArray(keys)) { - add(keys, obj); - return; - } - - let length = keys.length; - while (length-- > 0) { - add(keys[length], obj); - } - }); - - return buckets; -} - -/** - * Efficient and safe version of [].push(dest, source); - * - * @param {Array} source - the array to pull values from - * @param {Array} dest - the array to push values into - * @return {Array} dest - */ -export function pushAll(source: any[], dest: any[]): any[] { - const start = dest.length; - const adding = source.length; - - // allocate - http://goo.gl/e2i0S0 - dest.length = start + adding; - - // fill sparse positions - let i = -1; - while (++i < adding) { - dest[start + i] = source[i]; - } - - return dest; -} diff --git a/src/legacy/ui/public/utils/diff_object.js b/src/legacy/ui/public/utils/diff_object.js deleted file mode 100644 index ddad5e0ae42a0..0000000000000 --- a/src/legacy/ui/public/utils/diff_object.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; -import angular from 'angular'; - -export function applyDiff(target, source) { - const diff = {}; - - /** - * Filter the private vars - * @param {string} key The keys - * @returns {boolean} - */ - const filterPrivateAndMethods = function(obj) { - return function(key) { - if (_.isFunction(obj[key])) return false; - if (key.charAt(0) === '$') return false; - return key.charAt(0) !== '_'; - }; - }; - - const targetKeys = _.keys(target).filter(filterPrivateAndMethods(target)); - const sourceKeys = _.keys(source).filter(filterPrivateAndMethods(source)); - - // Find the keys to be removed - diff.removed = _.difference(targetKeys, sourceKeys); - - // Find the keys to be added - diff.added = _.difference(sourceKeys, targetKeys); - - // Find the keys that will be changed - diff.changed = _.filter(sourceKeys, function(key) { - return !angular.equals(target[key], source[key]); - }); - - // Make a list of all the keys that are changing - diff.keys = _.union(diff.changed, diff.removed, diff.added); - - // Remove all the keys - _.each(diff.removed, function(key) { - delete target[key]; - }); - - // Assign the changed to the source to the target - _.assign(target, _.pick(source, diff.changed)); - // Assign the added to the source to the target - _.assign(target, _.pick(source, diff.added)); - - return diff; -} diff --git a/src/legacy/ui/public/utils/math.test.ts b/src/legacy/ui/public/utils/math.test.ts deleted file mode 100644 index 13f090e77647b..0000000000000 --- a/src/legacy/ui/public/utils/math.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { greatestCommonDivisor, leastCommonMultiple } from './math'; - -describe('math utils', () => { - describe('greatestCommonDivisor', () => { - const tests: Array<[number, number, number]> = [ - [3, 5, 1], - [30, 36, 6], - [5, 1, 1], - [9, 9, 9], - [40, 20, 20], - [3, 0, 3], - [0, 5, 5], - [0, 0, 0], - [-9, -3, 3], - [-24, 8, 8], - [22, -7, 1], - ]; - - tests.map(([a, b, expected]) => { - it(`should return ${expected} for greatestCommonDivisor(${a}, ${b})`, () => { - expect(greatestCommonDivisor(a, b)).toBe(expected); - }); - }); - }); - - describe('leastCommonMultiple', () => { - const tests: Array<[number, number, number]> = [ - [3, 5, 15], - [1, 1, 1], - [5, 6, 30], - [3, 9, 9], - [8, 20, 40], - [5, 5, 5], - [0, 5, 0], - [-4, -5, 20], - [-2, -3, 6], - [-8, 2, 8], - [-8, 5, 40], - ]; - - tests.map(([a, b, expected]) => { - it(`should return ${expected} for leastCommonMultiple(${a}, ${b})`, () => { - expect(leastCommonMultiple(a, b)).toBe(expected); - }); - }); - }); -}); diff --git a/src/legacy/ui/public/vis/editors/config/editor_config_providers.ts b/src/legacy/ui/public/vis/editors/config/editor_config_providers.ts index 31fc0d90be101..c7fb937b97424 100644 --- a/src/legacy/ui/public/vis/editors/config/editor_config_providers.ts +++ b/src/legacy/ui/public/vis/editors/config/editor_config_providers.ts @@ -21,7 +21,7 @@ import { TimeIntervalParam } from 'ui/vis/editors/config/types'; import { AggConfig } from '../..'; import { AggType } from '../../../agg_types'; import { IndexPattern } from '../../../../../../plugins/data/public'; -import { leastCommonMultiple } from '../../../utils/math'; +import { leastCommonMultiple } from '../../lib/least_common_multiple'; import { parseEsInterval } from '../../../../../core_plugins/data/public'; import { leastCommonInterval } from '../../lib/least_common_interval'; import { EditorConfig, EditorParamConfig, FixedParam, NumericIntervalParam } from './types'; diff --git a/src/legacy/ui/public/vis/lib/least_common_interval.ts b/src/legacy/ui/public/vis/lib/least_common_interval.ts index dfdb099249228..244bc1d0111e3 100644 --- a/src/legacy/ui/public/vis/lib/least_common_interval.ts +++ b/src/legacy/ui/public/vis/lib/least_common_interval.ts @@ -18,7 +18,7 @@ */ import dateMath from '@elastic/datemath'; -import { leastCommonMultiple } from '../../utils/math'; +import { leastCommonMultiple } from './least_common_multiple'; import { parseEsInterval } from '../../../../core_plugins/data/public'; /** diff --git a/src/legacy/ui/public/utils/case_conversion.ts b/src/legacy/ui/public/vis/lib/least_common_multiple.test.ts similarity index 60% rename from src/legacy/ui/public/utils/case_conversion.ts rename to src/legacy/ui/public/vis/lib/least_common_multiple.test.ts index e0c4cad6b4e94..d07ac96b5f4d5 100644 --- a/src/legacy/ui/public/utils/case_conversion.ts +++ b/src/legacy/ui/public/vis/lib/least_common_multiple.test.ts @@ -17,20 +17,26 @@ * under the License. */ -// TODO: This file is copied from src/legacy/utils/case_conversion.ts -// because TS-imports from utils in ui are currently not possible. -// When the build process is updated, this file can be removed +import { leastCommonMultiple } from './least_common_multiple'; -import _ from 'lodash'; +describe('leastCommonMultiple', () => { + const tests: Array<[number, number, number]> = [ + [3, 5, 15], + [1, 1, 1], + [5, 6, 30], + [3, 9, 9], + [8, 20, 40], + [5, 5, 5], + [0, 5, 0], + [-4, -5, 20], + [-2, -3, 6], + [-8, 2, 8], + [-8, 5, 40], + ]; -export function keysToSnakeCaseShallow(object: Record) { - return _.mapKeys(object, (value, key) => { - return _.snakeCase(key); + tests.map(([a, b, expected]) => { + test(`should return ${expected} for leastCommonMultiple(${a}, ${b})`, () => { + expect(leastCommonMultiple(a, b)).toBe(expected); + }); }); -} - -export function keysToCamelCaseShallow(object: Record) { - return _.mapKeys(object, (value, key) => { - return _.camelCase(key); - }); -} +}); diff --git a/src/legacy/ui/public/utils/math.ts b/src/legacy/ui/public/vis/lib/least_common_multiple.ts similarity index 95% rename from src/legacy/ui/public/utils/math.ts rename to src/legacy/ui/public/vis/lib/least_common_multiple.ts index e0cab4236e2b8..dedddbf22ab44 100644 --- a/src/legacy/ui/public/utils/math.ts +++ b/src/legacy/ui/public/vis/lib/least_common_multiple.ts @@ -24,8 +24,10 @@ * This method does not properly work for fractional (non integer) numbers. If you * pass in fractional numbers there usually will be an output, but that's not necessarily * the greatest common divisor of those two numbers. + * + * @private */ -export function greatestCommonDivisor(a: number, b: number): number { +function greatestCommonDivisor(a: number, b: number): number { return a === 0 ? Math.abs(b) : greatestCommonDivisor(b % a, a); } @@ -36,6 +38,8 @@ export function greatestCommonDivisor(a: number, b: number): number { * Since this calculation suffers from rounding issues in decimal values, this method * won't work for passing in fractional (non integer) numbers. It will return a value, * but that value won't necessarily be the mathematical correct least common multiple. + * + * @internal */ export function leastCommonMultiple(a: number, b: number): number { return Math.abs((a * b) / greatestCommonDivisor(a, b)); diff --git a/src/legacy/utils/case_conversion.test.ts b/src/legacy/utils/case_conversion.test.ts deleted file mode 100644 index 27498f3878980..0000000000000 --- a/src/legacy/utils/case_conversion.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { keysToCamelCaseShallow, keysToSnakeCaseShallow } from './case_conversion'; - -describe('keysToSnakeCaseShallow', () => { - it("should convert all of an object's keys to snake case", () => { - const result = keysToSnakeCaseShallow({ - camelCase: 'camel_case', - 'kebab-case': 'kebab_case', - snake_case: 'snake_case', - }); - - expect(result).toMatchInlineSnapshot(` -Object { - "camel_case": "camel_case", - "kebab_case": "kebab_case", - "snake_case": "snake_case", -} -`); - }); -}); - -describe('keysToCamelCaseShallow', () => { - it("should convert all of an object's keys to camel case", () => { - const result = keysToCamelCaseShallow({ - camelCase: 'camelCase', - 'kebab-case': 'kebabCase', - snake_case: 'snakeCase', - }); - - expect(result).toMatchInlineSnapshot(` -Object { - "camelCase": "camelCase", - "kebabCase": "kebabCase", - "snakeCase": "snakeCase", -} -`); - }); -}); diff --git a/src/plugins/data/common/utils/index.ts b/src/plugins/data/common/utils/index.ts index 7196c96989e97..c5f1276feb81d 100644 --- a/src/plugins/data/common/utils/index.ts +++ b/src/plugins/data/common/utils/index.ts @@ -18,3 +18,4 @@ */ export { shortenDottedString } from './shorten_dotted_string'; +export { parseInterval } from './parse_interval'; diff --git a/src/plugins/data/common/utils/parse_interval.test.ts b/src/plugins/data/common/utils/parse_interval.test.ts new file mode 100644 index 0000000000000..0c02b02a25af0 --- /dev/null +++ b/src/plugins/data/common/utils/parse_interval.test.ts @@ -0,0 +1,119 @@ +/* + * 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 { Duration, unitOfTime } from 'moment'; +import { parseInterval } from './parse_interval'; + +const validateDuration = (duration: Duration | null, unit: unitOfTime.Base, value: number) => { + expect(duration).toBeDefined(); + + if (duration) { + expect(duration.as(unit)).toBe(value); + } +}; + +describe('parseInterval', () => { + describe('integer', () => { + test('should correctly parse 1d interval', () => { + validateDuration(parseInterval('1d'), 'd', 1); + }); + + test('should correctly parse 2y interval', () => { + validateDuration(parseInterval('2y'), 'y', 2); + }); + + test('should correctly parse 5M interval', () => { + validateDuration(parseInterval('5M'), 'M', 5); + }); + + test('should correctly parse 5m interval', () => { + validateDuration(parseInterval('5m'), 'm', 5); + }); + + test('should correctly parse 250ms interval', () => { + validateDuration(parseInterval('250ms'), 'ms', 250); + }); + + test('should correctly parse 100s interval', () => { + validateDuration(parseInterval('100s'), 's', 100); + }); + + test('should correctly parse 23d interval', () => { + validateDuration(parseInterval('23d'), 'd', 23); + }); + + test('should correctly parse 52w interval', () => { + validateDuration(parseInterval('52w'), 'w', 52); + }); + }); + + describe('fractional interval', () => { + test('should correctly parse fractional 2.35y interval', () => { + validateDuration(parseInterval('2.35y'), 'y', 2.35); + }); + + test('should correctly parse fractional 1.5w interval', () => { + validateDuration(parseInterval('1.5w'), 'w', 1.5); + }); + }); + + describe('less than 1', () => { + test('should correctly bubble up 0.5h interval which are less than 1', () => { + validateDuration(parseInterval('0.5h'), 'm', 30); + }); + + test('should correctly bubble up 0.5d interval which are less than 1', () => { + validateDuration(parseInterval('0.5d'), 'h', 12); + }); + }); + + describe('unit in an interval only', () => { + test('should correctly parse ms interval', () => { + validateDuration(parseInterval('ms'), 'ms', 1); + }); + + test('should correctly parse d interval', () => { + validateDuration(parseInterval('d'), 'd', 1); + }); + + test('should correctly parse m interval', () => { + validateDuration(parseInterval('m'), 'm', 1); + }); + + test('should correctly parse y interval', () => { + validateDuration(parseInterval('y'), 'y', 1); + }); + + test('should correctly parse M interval', () => { + validateDuration(parseInterval('M'), 'M', 1); + }); + }); + + test('should return null for an invalid interval', () => { + let duration = parseInterval(''); + expect(duration).toBeNull(); + + // @ts-ignore + duration = parseInterval(null); + expect(duration).toBeNull(); + + duration = parseInterval('234asdf'); + expect(duration).toBeNull(); + }); +}); diff --git a/src/legacy/ui/public/utils/parse_interval.js b/src/plugins/data/common/utils/parse_interval.ts similarity index 86% rename from src/legacy/ui/public/utils/parse_interval.js rename to src/plugins/data/common/utils/parse_interval.ts index fe484985ef101..ef1d89e400b72 100644 --- a/src/legacy/ui/public/utils/parse_interval.js +++ b/src/plugins/data/common/utils/parse_interval.ts @@ -17,14 +17,14 @@ * under the License. */ -import _ from 'lodash'; -import moment from 'moment'; +import { find } from 'lodash'; +import moment, { unitOfTime } from 'moment'; import dateMath from '@elastic/datemath'; // Assume interval is in the form (value)(unit), such as "1h" const INTERVAL_STRING_RE = new RegExp('^([0-9\\.]*)\\s*(' + dateMath.units.join('|') + ')$'); -export function parseInterval(interval) { +export function parseInterval(interval: string): moment.Duration | null { const matches = String(interval) .trim() .match(INTERVAL_STRING_RE); @@ -33,7 +33,7 @@ export function parseInterval(interval) { try { const value = parseFloat(matches[1]) || 1; - const unit = matches[2]; + const unit = matches[2] as unitOfTime.Base; const duration = moment.duration(value, unit); @@ -44,9 +44,10 @@ export function parseInterval(interval) { // adding 0.5 days until we hit the end date. However, since there is a bug in moment, when you add 0.5 days to // the start date, you get the same exact date (instead of being ahead by 12 hours). So instead of returning // a duration corresponding to 0.5 hours, we return a duration corresponding to 12 hours. - const selectedUnit = _.find(dateMath.units, function(unit) { - return Math.abs(duration.as(unit)) >= 1; - }); + const selectedUnit = find( + dateMath.units, + u => Math.abs(duration.as(u)) >= 1 + ) as unitOfTime.Base; return moment.duration(duration.as(selectedUnit), selectedUnit); } catch (e) { diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 967887764237d..4b330600417e7 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -90,6 +90,8 @@ export { castEsToKbnFieldTypeName, getKbnFieldType, getKbnTypeNames, + // utils + parseInterval, } from '../common'; // Export plugin after all other imports diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index fe96c494bd9ff..8194e9a6c847e 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -47,6 +47,8 @@ export { // timefilter RefreshInterval, TimeRange, + // utils + parseInterval, } from '../common'; /** diff --git a/src/test_utils/public/simulate_keys.js b/src/test_utils/public/simulate_keys.js index 8ee4a79e6bc7c..a876d67325c05 100644 --- a/src/test_utils/public/simulate_keys.js +++ b/src/test_utils/public/simulate_keys.js @@ -20,7 +20,7 @@ import $ from 'jquery'; import _ from 'lodash'; import Bluebird from 'bluebird'; -import { keyMap } from 'ui/utils/key_map'; +import { keyMap } from 'ui/directives/key_map'; const reverseKeyMap = _.mapValues(_.invert(keyMap), _.ary(_.parseInt, 1)); /** diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/field.js b/x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/field.js index 82e8c602a7a3e..3e5c92dfc007f 100644 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/field.js +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/field.js @@ -6,7 +6,7 @@ import React from 'react'; import { flatten } from 'lodash'; import { escapeKuery } from './escape_kuery'; -import { sortPrefixFirst } from 'ui/utils/sort_prefix_first'; +import { sortPrefixFirst } from './sort_prefix_first'; import { isFilterable } from '../../../../../../src/plugins/data/public'; import { FormattedMessage } from '@kbn/i18n/react'; diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/sort_prefix_first.test.ts b/x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/sort_prefix_first.test.ts new file mode 100644 index 0000000000000..7db05a60377da --- /dev/null +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/sort_prefix_first.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { sortPrefixFirst } from './sort_prefix_first'; + +describe('sortPrefixFirst', () => { + test('should return the original unmodified array if no prefix is provided', () => { + const array = ['foo', 'bar', 'baz']; + const result = sortPrefixFirst(array); + + expect(result).toBe(array); + expect(result).toEqual(['foo', 'bar', 'baz']); + }); + + test('should sort items that match the prefix first without modifying the original array', () => { + const array = ['foo', 'bar', 'baz']; + const result = sortPrefixFirst(array, 'b'); + + expect(result).not.toBe(array); + expect(result).toEqual(['bar', 'baz', 'foo']); + expect(array).toEqual(['foo', 'bar', 'baz']); + }); + + test('should not modify the order of the array other than matching prefix without modifying the original array', () => { + const array = ['foo', 'bar', 'baz', 'qux', 'quux']; + const result = sortPrefixFirst(array, 'b'); + + expect(result).not.toBe(array); + expect(result).toEqual(['bar', 'baz', 'foo', 'qux', 'quux']); + expect(array).toEqual(['foo', 'bar', 'baz', 'qux', 'quux']); + }); + + test('should sort objects by property if provided', () => { + const array = [ + { name: 'foo' }, + { name: 'bar' }, + { name: 'baz' }, + { name: 'qux' }, + { name: 'quux' }, + ]; + const result = sortPrefixFirst(array, 'b', 'name'); + + expect(result).not.toBe(array); + expect(result).toEqual([ + { name: 'bar' }, + { name: 'baz' }, + { name: 'foo' }, + { name: 'qux' }, + { name: 'quux' }, + ]); + expect(array).toEqual([ + { name: 'foo' }, + { name: 'bar' }, + { name: 'baz' }, + { name: 'qux' }, + { name: 'quux' }, + ]); + }); + + test('should handle numbers', () => { + const array = [1, 50, 5]; + const result = sortPrefixFirst(array, 5); + + expect(result).not.toBe(array); + expect(result).toEqual([50, 5, 1]); + }); + + test('should handle mixed case', () => { + const array = ['Date Histogram', 'Histogram']; + const prefix = 'histo'; + const result = sortPrefixFirst(array, prefix); + + expect(result).not.toBe(array); + expect(result).toEqual(['Histogram', 'Date Histogram']); + }); +}); diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/sort_prefix_first.ts b/x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/sort_prefix_first.ts new file mode 100644 index 0000000000000..123e440b75231 --- /dev/null +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/autocomplete_providers/sort_prefix_first.ts @@ -0,0 +1,20 @@ +/* + * 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 { partition } from 'lodash'; + +export function sortPrefixFirst(array: any[], prefix?: string | number, property?: string): any[] { + if (!prefix) { + return array; + } + const lowerCasePrefix = ('' + prefix).toLowerCase(); + + const partitions = partition(array, entry => { + const value = ('' + (property ? entry[property] : entry)).toLowerCase(); + return value.startsWith(lowerCasePrefix); + }); + return [...partitions[0], ...partitions[1]]; +} From 392e62a4de9f4a8cc92e9f6e2a0a0b3bf8da6930 Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Wed, 8 Jan 2020 13:31:07 +0200 Subject: [PATCH 04/63] =?UTF-8?q?[Telemetry]=20Fix=20license=20page=20cras?= =?UTF-8?q?hing=20on=20telemetry.enabled:=20fa=E2=80=A6=20(#54174)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../legacy/plugins/license_management/public/register_route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/license_management/public/register_route.ts b/x-pack/legacy/plugins/license_management/public/register_route.ts index 994a888ac020a..0fe29b543be5b 100644 --- a/x-pack/legacy/plugins/license_management/public/register_route.ts +++ b/x-pack/legacy/plugins/license_management/public/register_route.ts @@ -52,7 +52,7 @@ if (licenseManagementUiEnabled) { }; const initializeTelemetry = ($injector: any) => { - const telemetryEnabled = $injector.get('telemetryEnabled'); + const telemetryEnabled = npStart.core.injectedMetadata.getInjectedVar('telemetryEnabled'); const Private = $injector.get('Private'); const telemetryOptInProvider = Private(TelemetryOptInProvider); setTelemetryOptInService(telemetryOptInProvider); From 3b5a90b51cc69f056f742a49cde69bf53927ea8b Mon Sep 17 00:00:00 2001 From: Tim Schnell Date: Wed, 8 Jan 2020 07:41:52 -0600 Subject: [PATCH 05/63] fix ecommerce percentages in sample data (#54200) --- .../server/sample_data/ecommerce_saved_objects.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/legacy/plugins/canvas/server/sample_data/ecommerce_saved_objects.json b/x-pack/legacy/plugins/canvas/server/sample_data/ecommerce_saved_objects.json index 39970c6d66b7a..113fba5555376 100644 --- a/x-pack/legacy/plugins/canvas/server/sample_data/ecommerce_saved_objects.json +++ b/x-pack/legacy/plugins/canvas/server/sample_data/ecommerce_saved_objects.json @@ -194,7 +194,7 @@ "height": 56, "angle": 0 }, - "expression": "esdocs index=\"kibana_sample_data_ecommerce\" fields=\"customer_gender\" \n| ply by=\"customer_gender\" expression={rowCount | as \"percentage\"} \n| filterrows {getCell \"customer_gender\" | any {eq \"FEMALE\"}} \n| markdown {getCell \"percentage\"} \"%\" font={font family=\"Avenir\" size=48 align=\"center\" color=\"#eb6c66\" weight=\"normal\" underline=false italic=false}" + "expression": "esdocs index=\"kibana_sample_data_ecommerce\" sort=\"order_date, desc\" fields=\"customer_gender\" | mapColumn \"femaleCount\" exp={getCell \"customer_gender\" | if {compare to=\"FEMALE\"} then=1 else=0} | math \"round(100 * sum(femaleCount) / count(femaleCount))\" | markdown {context} \"%\" font={font family=\"Avenir\" size=48 align=\"center\" color=\"#eb6c66\" weight=\"normal\" underline=false italic=false}" }, { "id": "element-9e0b6230-2bc9-4995-8207-043e3063faeb", @@ -205,7 +205,7 @@ "height": 56, "angle": 0 }, - "expression": "esdocs index=\"kibana_sample_data_ecommerce\" fields=\"customer_gender\" \n| ply by=\"customer_gender\" expression={rowCount | as \"percentage\"} \n| filterrows {getCell \"customer_gender\" | any {eq \"MALE\"}} \n| markdown {getCell \"percentage\"} \"%\" font={font family=\"Avenir\" size=48 align=\"center\" color=\"#f8bd4a\" weight=\"normal\" underline=false italic=false}" + "expression": "esdocs index=\"kibana_sample_data_ecommerce\" sort=\"order_date, desc\" fields=\"customer_gender\" | mapColumn \"maleCount\" exp={getCell \"customer_gender\" | if {compare to=\"MALE\"} then=1 else=0} | math \"round(100 * sum(maleCount) / count(maleCount))\" | markdown {context} \"%\" font={font family=\"Avenir\" size=48 align=\"center\" color=\"#f8bd4a\" weight=\"normal\" underline=false italic=false}" }, { "id": "element-2185edff-ac50-4162-b583-3bfd6469e925", @@ -959,23 +959,23 @@ "id": "element-b8f13a87-b781-42d5-a663-5dbd4f645d6d", "position": { "left": 837, - "top": 472, + "top": 468, "width": 91, "height": 63, "angle": 0 }, - "expression": "esdocs index=\"kibana_sample_data_ecommerce\" fields=\"customer_gender\" \n| ply by=\"customer_gender\" expression={rowCount | as \"percentage\"} \n| filterrows {getCell \"customer_gender\" | any {eq \"FEMALE\"}} \n| markdown {getCell \"percentage\"} font={font family=\"nexa bold, Avenir\" size=48 align=\"center\" color=\"#F05A24\" weight=\"bold\" underline=false italic=false}" + "expression": "esdocs index=\"kibana_sample_data_ecommerce\" sort=\"order_date, desc\" fields=\"customer_gender\" | mapColumn \"femaleCount\" exp={getCell \"customer_gender\" | if {compare to=\"FEMALE\"} then=1 else=0} | math \"round(100 * sum(femaleCount) / count(femaleCount))\" | markdown {context} font={font family=\"nexa bold, Avenir\" size=48 align=\"center\" color=\"#F05A24\" weight=\"bold\" underline=false italic=false}" }, { "id": "element-86b06b67-893e-4555-ad38-7fba9ea3153b", "position": { "left": 837, - "top": 219, + "top": 215, "width": 90, "height": 72, "angle": 0 }, - "expression": "esdocs index=\"kibana_sample_data_ecommerce\" fields=\"customer_gender\" \n| ply by=\"customer_gender\" expression={rowCount | as \"percentage\"} \n| filterrows {getCell \"customer_gender\" | any {eq \"MALE\"}} \n| markdown {getCell \"percentage\"} font={font family=\"nexa bold, Avenir\" size=48 align=\"center\" color=\"#00A89C\" weight=\"bold\" underline=false italic=false}" + "expression": "esdocs index=\"kibana_sample_data_ecommerce\" sort=\"order_date, desc\" fields=\"customer_gender\" | mapColumn \"maleCount\" exp={getCell \"customer_gender\" | if {compare to=\"MALE\"} then=1 else=0} | math \"round(100 * sum(maleCount) / count(maleCount))\" | markdown {context} font={font family=\"nexa bold, Avenir\" size=48 align=\"center\" color=\"#00A89C\" weight=\"bold\" underline=false italic=false}" }, { "id": "element-507337d9-6e0e-4752-8770-6ebe88e9b3da", From fdea7b048d4b9de69ee6da722cccec239c386890 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Wed, 8 Jan 2020 14:53:58 +0100 Subject: [PATCH 06/63] Add explanation of path.data to contribution guide (#54235) * Add explanation of path.data to contribution guide * Change title slightly --- CONTRIBUTING.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06e08c85dafec..6ae3db559b61b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -190,6 +190,19 @@ These snapshots are built on a nightly basis which expire after a couple weeks. yarn es snapshot ``` +##### Keeping data between snapshots + +If you want to keep the data inside your Elasticsearch between usages of this command, +you should use the following command, to keep your data folder outside the downloaded snapshot +folder: + +```bash +yarn es snapshot -E path.data=../data +``` + +The same parameter can be used with the source and archive command shown in the following +paragraphs. + #### Source By default, it will reference an [elasticsearch](https://github.com/elastic/elasticsearch) checkout which is a sibling to the Kibana directory named `elasticsearch`. If you wish to use a checkout in another location you can provide that by supplying `--source-path` From 6abfbd138255e8c358e06397997ca302b9f13aa6 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 8 Jan 2020 08:58:44 -0500 Subject: [PATCH 07/63] filters are back (#54218) --- .../siem/public/pages/hosts/hosts.test.tsx | 60 +++++++++++++++++- .../plugins/siem/public/pages/hosts/hosts.tsx | 5 +- .../public/pages/network/network.test.tsx | 61 ++++++++++++++++++- .../siem/public/pages/network/network.tsx | 3 +- 4 files changed, 122 insertions(+), 7 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.test.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.test.tsx index f7e0863465f9a..065d91b3fc2fa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.test.tsx @@ -11,14 +11,18 @@ import { Router } from 'react-router-dom'; import { MockedProvider } from 'react-apollo/test-utils'; import { ActionCreator } from 'typescript-fsa'; +import { esFilters } from '../../../../../../../src/plugins/data/common/es_query'; import '../../mock/match_media'; import { mocksSource } from '../../containers/source/mock'; import { wait } from '../../lib/helpers'; -import { TestProviders } from '../../mock'; +import { apolloClientObservable, TestProviders, mockGlobalState } from '../../mock'; import { InputsModelId } from '../../store/inputs/constants'; import { SiemNavigation } from '../../components/navigation'; +import { inputsActions } from '../../store/inputs'; +import { State, createStore } from '../../store'; import { HostsComponentProps } from './types'; import { Hosts } from './hosts'; +import { HostsTabs } from './hosts_tabs'; // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar @@ -136,4 +140,58 @@ describe('Hosts - rendering', () => { wrapper.update(); expect(wrapper.find(SiemNavigation).exists()).toBe(true); }); + + test('it should add the new filters after init', async () => { + const newFilters: esFilters.Filter[] = [ + { + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + match_phrase: { + 'host.name': 'ItRocks', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + meta: { + alias: '', + disabled: false, + key: 'bool', + negate: false, + type: 'custom', + value: + '{"query": {"bool": {"filter": [{"bool": {"should": [{"match_phrase": {"host.name": "ItRocks"}}],"minimum_should_match": 1}}]}}}', + }, + }, + ]; + localSource[0].result.data.source.status.indicesExist = true; + const myState: State = mockGlobalState; + const myStore = createStore(myState, apolloClientObservable); + const wrapper = mount( + + + + + + + + ); + await wait(); + wrapper.update(); + + myStore.dispatch(inputsActions.setSearchBarFilter({ id: 'global', filters: newFilters })); + wrapper.update(); + expect(wrapper.find(HostsTabs).props().filterQuery).toEqual( + '{"bool":{"must":[],"filter":[{"match_all":{}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"host.name":"ItRocks"}}],"minimum_should_match":1}}]}}],"should":[],"must_not":[]}}' + ); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx index 6b69f06b97b83..2c475e4ba6ac5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx @@ -41,7 +41,7 @@ import { HostsTableType } from '../../store/hosts/model'; const KpiHostsComponentManage = manageQuery(KpiHostsComponent); -const HostsComponent = React.memo( +export const HostsComponent = React.memo( ({ deleteQuery, isInitializing, @@ -56,13 +56,12 @@ const HostsComponent = React.memo( const capabilities = React.useContext(MlCapabilitiesContext); const kibana = useKibana(); const { tabName } = useParams(); - const hostsFilters = React.useMemo(() => { if (tabName === HostsTableType.alerts) { return filters.length > 0 ? [...filters, ...filterAlertsHosts] : filterAlertsHosts; } return filters; - }, [tabName]); + }, [tabName, filters]); const narrowDateRange = useCallback( (min: number, max: number) => { setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); diff --git a/x-pack/legacy/plugins/siem/public/pages/network/network.test.tsx b/x-pack/legacy/plugins/siem/public/pages/network/network.test.tsx index 26b2bd9dace48..3a22e800d893f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/network/network.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/network/network.test.tsx @@ -11,10 +11,13 @@ import { Router } from 'react-router-dom'; import { MockedProvider } from 'react-apollo/test-utils'; import '../../mock/match_media'; - +import { esFilters } from '../../../../../../../src/plugins/data/common/es_query'; import { mocksSource } from '../../containers/source/mock'; -import { TestProviders } from '../../mock'; +import { TestProviders, mockGlobalState, apolloClientObservable } from '../../mock'; +import { State, createStore } from '../../store'; +import { inputsActions } from '../../store/inputs'; import { Network } from './network'; +import { NetworkRoutes } from './navigation'; // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar @@ -111,4 +114,58 @@ describe('rendering - rendering', () => { wrapper.update(); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); }); + + test('it should add the new filters after init', async () => { + const newFilters: esFilters.Filter[] = [ + { + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + match_phrase: { + 'host.name': 'ItRocks', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + meta: { + alias: '', + disabled: false, + key: 'bool', + negate: false, + type: 'custom', + value: + '{"query": {"bool": {"filter": [{"bool": {"should": [{"match_phrase": {"host.name": "ItRocks"}}],"minimum_should_match": 1}}]}}}', + }, + }, + ]; + localSource[0].result.data.source.status.indicesExist = true; + const myState: State = mockGlobalState; + const myStore = createStore(myState, apolloClientObservable); + const wrapper = mount( + + + + + + + + ); + await new Promise(resolve => setTimeout(resolve)); + wrapper.update(); + + myStore.dispatch(inputsActions.setSearchBarFilter({ id: 'global', filters: newFilters })); + wrapper.update(); + expect(wrapper.find(NetworkRoutes).props().filterQuery).toEqual( + '{"bool":{"must":[],"filter":[{"match_all":{}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"host.name":"ItRocks"}}],"minimum_should_match":1}}]}}],"should":[],"must_not":[]}}' + ); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/network/network.tsx b/x-pack/legacy/plugins/siem/public/pages/network/network.tsx index c39935742a2e0..ad3513a6f529e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/network/network.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/network/network.tsx @@ -59,7 +59,8 @@ const NetworkComponent = React.memo( return filters.length > 0 ? [...filters, ...filterAlertsNetwork] : filterAlertsNetwork; } return filters; - }, [tabName]); + }, [tabName, filters]); + const narrowDateRange = useCallback( (min: number, max: number) => { setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); From 683c138798de8fdc65d915af7616ee21ab45df5b Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Wed, 8 Jan 2020 07:26:54 -0700 Subject: [PATCH 08/63] Add file upload to telemetry integration tests (#53539) Co-authored-by: Elastic Machine --- x-pack/test/api_integration/apis/telemetry/telemetry_local.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/x-pack/test/api_integration/apis/telemetry/telemetry_local.js b/x-pack/test/api_integration/apis/telemetry/telemetry_local.js index ef7124e2864f8..db705b301a71e 100644 --- a/x-pack/test/api_integration/apis/telemetry/telemetry_local.js +++ b/x-pack/test/api_integration/apis/telemetry/telemetry_local.js @@ -86,6 +86,9 @@ export default function({ getService }) { expect(stats.stack_stats.kibana.plugins.reporting.enabled).to.be(true); expect(stats.stack_stats.kibana.plugins.rollups.index_patterns).to.be.an('object'); expect(stats.stack_stats.kibana.plugins.spaces.available).to.be(true); + expect(stats.stack_stats.kibana.plugins.fileUploadTelemetry.filesUploadedTotalCount).to.be.a( + 'number' + ); expect(stats.stack_stats.kibana.os.platforms[0].platform).to.be.a('string'); expect(stats.stack_stats.kibana.os.platforms[0].count).to.be(1); From 16b217a11bf901b2c40fbd36c416a4543150cdd2 Mon Sep 17 00:00:00 2001 From: Court Ewing Date: Wed, 8 Jan 2020 09:32:58 -0500 Subject: [PATCH 09/63] [DOCS] consolidate telemetry settings to core (#52634) * docs: consolidate telemetry settings to core Telemetry is no longer part of monitoring. * docs: revise telemetry settings text * docs: tweak telemetry setting text --- docs/settings/monitoring-settings.asciidoc | 7 ------- docs/setup/settings.asciidoc | 5 +++++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/settings/monitoring-settings.asciidoc b/docs/settings/monitoring-settings.asciidoc index 2fc74d2ffee32..38a46a3cde5a0 100644 --- a/docs/settings/monitoring-settings.asciidoc +++ b/docs/settings/monitoring-settings.asciidoc @@ -66,13 +66,6 @@ both the {es} monitoring cluster and the {es} production cluster. If not set, {kib} uses the value of the `elasticsearch.password` setting. -`telemetry.enabled`:: -Set to `true` (default) to send cluster statistics to Elastic. Reporting your -cluster statistics helps us improve your user experience. Your data is never -shared with anyone. Set to `false` to disable statistics reporting from any -browser connected to the {kib} instance. You can also opt out through the -*Advanced Settings* in {kib}. - `xpack.monitoring.elasticsearch.pingTimeout`:: Specifies the time in milliseconds to wait for {es} to respond to internal health checks. By default, it matches the `elasticsearch.pingTimeout` setting, diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 01e6bd51ea50b..c98df6ca78356 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -380,6 +380,11 @@ cannot be `false` at the same time. To enable telemetry and prevent users from disabling it, set `telemetry.allowChangingOptInStatus` to `false` and `telemetry.optIn` to `true`. +`telemetry.enabled`:: *Default: true* Reporting your cluster statistics helps +us improve your user experience. Your data is never shared with anyone. Set to +`false` to disable telemetry capabilities entirely. You can alternatively opt +out through the *Advanced Settings* in {kib}. + `vega.enableExternalUrls:`:: *Default: false* Set this value to true to allow Vega to use any URL to access external data sources and images. If false, Vega can only get data from Elasticsearch. `xpack.license_management.enabled`:: *Default: true* Set this value to false to From a93c23cd567192784d249a211ec14d39e92c0a63 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Wed, 8 Jan 2020 09:53:50 -0500 Subject: [PATCH 10/63] Reorganize structure of component render to avoid errors. (#54251) --- .../functional/charts/duration_chart.tsx | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx index b5dcfce032724..775e8c0c06aa5 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx @@ -66,32 +66,32 @@ export const DurationChart = ({ - - - - getTickFormat(d)} - title={i18n.translate('xpack.uptime.monitorCharts.durationChart.leftAxis.title', { - defaultMessage: 'Duration ms', - })} - /> - {hasLines ? ( + {hasLines ? ( + + + + getTickFormat(d)} + title={i18n.translate('xpack.uptime.monitorCharts.durationChart.leftAxis.title', { + defaultMessage: 'Duration ms', + })} + /> - ) : ( - - )} - + + ) : ( + + )} From 63ac99b3aaf0b2e6ad51cdf642bfee13ae0b5d7c Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 8 Jan 2020 15:59:06 +0100 Subject: [PATCH 11/63] Re-enable OIDC API integration test. (#54111) --- .../test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts index 630ec2792b9bf..1f5a64835416a 100644 --- a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts +++ b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts @@ -100,7 +100,7 @@ export default function({ getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/43938 - it.skip('should succeed if both the OpenID Connect response and the cookie are provided', async () => { + it('should succeed if both the OpenID Connect response and the cookie are provided', async () => { const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce); const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`; From 8e0e4948d562be5373e89c53861449517e4c2729 Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Wed, 8 Jan 2020 16:33:51 +0100 Subject: [PATCH 12/63] [SIEM] Fix columns in timeline do not resize (#51816) --- x-pack/legacy/plugins/siem/package.json | 2 +- .../components/fields_browser/index.tsx | 4 +- .../components/flyout/pane/index.test.tsx | 2 +- .../public/components/flyout/pane/index.tsx | 160 ++++++++------- .../pane/timeline_resize_handle.tsx} | 7 +- .../__snapshots__/index.test.tsx.snap | 14 -- .../components/resize_handle/index.test.tsx | 184 ----------------- .../public/components/resize_handle/index.tsx | 145 ------------- .../components/resize_handle/is_resizing.tsx | 16 -- .../__snapshots__/index.test.tsx.snap | 22 +- .../body/column_headers/column_header.tsx | 99 +++++++++ .../common/dragging_container.tsx | 24 +++ .../header/__snapshots__/index.test.tsx.snap | 56 ++++- .../column_headers/header/header_content.tsx | 76 +++++++ .../body/column_headers/header/index.test.tsx | 43 ---- .../body/column_headers/header/index.tsx | 123 ++--------- .../body/column_headers/index.test.tsx | 40 ---- .../timeline/body/column_headers/index.tsx | 194 +++++++----------- .../__snapshots__/index.test.tsx.snap | 60 +++--- .../public/components/timeline/styles.tsx | 81 ++++---- x-pack/package.json | 1 + yarn.lock | 20 +- 22 files changed, 509 insertions(+), 864 deletions(-) rename x-pack/legacy/plugins/siem/public/components/{resize_handle/styled_handles.tsx => flyout/pane/timeline_resize_handle.tsx} (70%) delete mode 100644 x-pack/legacy/plugins/siem/public/components/resize_handle/__snapshots__/index.test.tsx.snap delete mode 100644 x-pack/legacy/plugins/siem/public/components/resize_handle/index.test.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/resize_handle/is_resizing.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/common/dragging_container.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx diff --git a/x-pack/legacy/plugins/siem/package.json b/x-pack/legacy/plugins/siem/package.json index bf5d6d3a3089c..558ac013e5963 100644 --- a/x-pack/legacy/plugins/siem/package.json +++ b/x-pack/legacy/plugins/siem/package.json @@ -13,7 +13,7 @@ "devDependencies": { "@types/lodash": "^4.14.110", "@types/js-yaml": "^3.12.1", - "@types/react-beautiful-dnd": "^11.0.3" + "@types/react-beautiful-dnd": "^11.0.4" }, "dependencies": { "lodash": "^4.17.15", diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx index 3958cd463d56e..c8cde5fa02a51 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx @@ -212,9 +212,7 @@ export const StatefulFieldsBrowserComponent = React.memo { ); - expect(wrapper.find('[data-test-subj="eui-flyout"]').get(0).props.maxWidth).toEqual('95%'); + expect(wrapper.find('Resizable').get(0).props.maxWidth).toEqual('95vw'); }); test('it applies timeline styles to the EuiFlyout', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx index f2f0cf4f980f3..00ac15092a6ec 100644 --- a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx @@ -5,13 +5,14 @@ */ import { EuiButtonIcon, EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiToolTip } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { connect } from 'react-redux'; import styled from 'styled-components'; import { ActionCreator } from 'typescript-fsa'; +import { Resizable, ResizeCallback } from 're-resizable'; +import { throttle } from 'lodash/fp'; -import { OnResize, Resizeable } from '../../resize_handle'; -import { TimelineResizeHandle } from '../../resize_handle/styled_handles'; +import { TimelineResizeHandle } from './timeline_resize_handle'; import { FlyoutHeader } from '../header'; import * as i18n from './translations'; @@ -41,10 +42,10 @@ interface DispatchProps { type Props = OwnProps & DispatchProps; -const EuiFlyoutContainer = styled.div<{ headerHeight: number; width: number }>` +const EuiFlyoutContainer = styled.div<{ headerHeight: number }>` .timeline-flyout { min-width: 150px; - width: ${({ width }) => `${width}px`}; + width: auto; } .timeline-flyout-header { align-items: center; @@ -65,8 +66,6 @@ const EuiFlyoutContainer = styled.div<{ headerHeight: number; width: number }>` } `; -EuiFlyoutContainer.displayName = 'EuiFlyoutContainer'; - const FlyoutHeaderContainer = styled.div` align-items: center; display: flex; @@ -75,88 +74,95 @@ const FlyoutHeaderContainer = styled.div` width: 100%; `; -FlyoutHeaderContainer.displayName = 'FlyoutHeaderContainer'; - // manually wrap the close button because EuiButtonIcon can't be a wrapped `styled` const WrappedCloseButton = styled.div` margin-right: 5px; `; -WrappedCloseButton.displayName = 'WrappedCloseButton'; - -const FlyoutHeaderWithCloseButton = React.memo<{ +const FlyoutHeaderWithCloseButtonComponent: React.FC<{ onClose: () => void; timelineId: string; usersViewing: string[]; -}>( - ({ onClose, timelineId, usersViewing }) => ( - - - - - - - - - ), +}> = ({ onClose, timelineId, usersViewing }) => ( + + + + + + + + +); + +const FlyoutHeaderWithCloseButton = React.memo( + FlyoutHeaderWithCloseButtonComponent, (prevProps, nextProps) => prevProps.timelineId === nextProps.timelineId && prevProps.usersViewing === nextProps.usersViewing ); -FlyoutHeaderWithCloseButton.displayName = 'FlyoutHeaderWithCloseButton'; - -const FlyoutPaneComponent = React.memo( - ({ - applyDeltaToWidth, - children, - flyoutHeight, - headerHeight, - onClose, - timelineId, - usersViewing, - width, - }) => { - const renderFlyout = useCallback(() => <>, []); - - const onResize: OnResize = useCallback( - ({ delta, id }) => { - const bodyClientWidthPixels = document.body.clientWidth; - +const FlyoutPaneComponent: React.FC = ({ + applyDeltaToWidth, + children, + flyoutHeight, + headerHeight, + onClose, + timelineId, + usersViewing, + width, +}) => { + const [lastDelta, setLastDelta] = useState(0); + const onResizeStop: ResizeCallback = useCallback( + (e, direction, ref, delta) => { + const bodyClientWidthPixels = document.body.clientWidth; + + if (delta.width) { applyDeltaToWidth({ bodyClientWidthPixels, - delta, - id, + delta: -(delta.width - lastDelta), + id: timelineId, maxWidthPercent, minWidthPixels, }); - }, - [applyDeltaToWidth, maxWidthPercent, minWidthPixels] - ); - return ( - - - setLastDelta(0), [setLastDelta]); + const throttledResize = throttle(100, onResizeStop); + + return ( + + + - } - id={timelineId} - onResize={onResize} - render={renderFlyout} - /> + ), + }} + onResizeStart={resetLastDelta} + onResize={throttledResize} + > ( {children} - - - ); - } -); - -FlyoutPaneComponent.displayName = 'FlyoutPaneComponent'; + + + + ); +}; export const Pane = connect(null, { applyDeltaToWidth: timelineActions.applyDeltaToWidth, -})(FlyoutPaneComponent); +})(React.memo(FlyoutPaneComponent)); Pane.displayName = 'Pane'; diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/styled_handles.tsx b/x-pack/legacy/plugins/siem/public/components/flyout/pane/timeline_resize_handle.tsx similarity index 70% rename from x-pack/legacy/plugins/siem/public/components/resize_handle/styled_handles.tsx rename to x-pack/legacy/plugins/siem/public/components/flyout/pane/timeline_resize_handle.tsx index 4f641c5d2042e..3ee29c2eaaa16 100644 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/styled_handles.tsx +++ b/x-pack/legacy/plugins/siem/public/components/flyout/pane/timeline_resize_handle.tsx @@ -8,18 +8,13 @@ import styled from 'styled-components'; export const TIMELINE_RESIZE_HANDLE_WIDTH = 2; // px -export const CommonResizeHandle = styled.div` +export const TimelineResizeHandle = styled.div<{ height: number }>` cursor: col-resize; height: 100%; min-height: 20px; width: 0; -`; -CommonResizeHandle.displayName = 'CommonResizeHandle'; - -export const TimelineResizeHandle = styled(CommonResizeHandle)<{ height: number }>` border: ${TIMELINE_RESIZE_HANDLE_WIDTH}px solid ${props => props.theme.eui.euiColorLightShade}; z-index: 2; height: ${({ height }) => `${height}px`}; position: absolute; `; -TimelineResizeHandle.displayName = 'TimelineResizeHandle'; diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/resize_handle/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 38027f80e6684..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Resizeable it renders 1`] = ` - - - - - -`; diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/resize_handle/index.test.tsx deleted file mode 100644 index 1237a6538a4c1..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.test.tsx +++ /dev/null @@ -1,184 +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 { mount, shallow } from 'enzyme'; -import React from 'react'; - -import { TestProviders } from '../../mock/test_providers'; - -import { - addGlobalResizeCursorStyleToBody, - globalResizeCursorClassName, - removeGlobalResizeCursorStyleFromBody, - Resizeable, - calculateDeltaX, -} from '.'; -import { CommonResizeHandle } from './styled_handles'; - -describe('Resizeable', () => { - afterEach(() => { - document.body.classList.remove(globalResizeCursorClassName); - }); - - test('it applies the provided height to the ResizeHandleContainer when a height is specified', () => { - const wrapper = mount( - - } - height="100%" - id="test" - onResize={jest.fn()} - render={() => <>} - /> - - ); - - expect(wrapper.find('[data-test-subj="resize-handle-container"]').first()).toHaveStyleRule( - 'height', - '100%' - ); - }); - - test('it applies positioning styles to the ResizeHandleContainer when positionAbsolute is true and bottom/left/right/top is specified', () => { - const wrapper = mount( - - } - id="test" - left={0} - onResize={jest.fn()} - positionAbsolute - render={() => <>} - right={0} - top={0} - /> - - ); - const resizeHandleContainer = wrapper - .find('[data-test-subj="resize-handle-container"]') - .first(); - - expect(resizeHandleContainer).toHaveStyleRule('bottom', '0'); - expect(resizeHandleContainer).toHaveStyleRule('left', '0'); - expect(resizeHandleContainer).toHaveStyleRule('position', 'absolute'); - expect(resizeHandleContainer).toHaveStyleRule('right', '0'); - expect(resizeHandleContainer).toHaveStyleRule('top', '0'); - }); - - test('it DOES NOT apply positioning styles to the ResizeHandleContainer when positionAbsolute is false, regardless if bottom/left/right/top is specified', () => { - const wrapper = mount( - - } - id="test" - left={0} - onResize={jest.fn()} - render={() => <>} - right={0} - top={0} - /> - - ); - const resizeHandleContainer = wrapper - .find('[data-test-subj="resize-handle-container"]') - .first(); - - expect(resizeHandleContainer).not.toHaveStyleRule('bottom', '0'); - expect(resizeHandleContainer).not.toHaveStyleRule('left', '0'); - expect(resizeHandleContainer).not.toHaveStyleRule('position', 'absolute'); - expect(resizeHandleContainer).not.toHaveStyleRule('right', '0'); - expect(resizeHandleContainer).not.toHaveStyleRule('top', '0'); - }); - - test('it renders', () => { - const wrapper = shallow( - } - height="100%" - id="test" - onResize={jest.fn()} - render={() => <>} - /> - ); - - expect(wrapper).toMatchSnapshot(); - }); - - describe('resize cursor styling', () => { - test('it does NOT apply the global-resize-cursor style to the body by default', () => { - mount( - - } - height="100%" - id="test" - onResize={jest.fn()} - render={() => <>} - /> - - ); - - expect(document.body.className).not.toContain(globalResizeCursorClassName); - }); - - describe('#addGlobalResizeCursorStyleToBody', () => { - test('it adds the global-resize-cursor style to the body', () => { - mount( - - } - height="100%" - id="test" - onResize={jest.fn()} - render={() => <>} - /> - - ); - - addGlobalResizeCursorStyleToBody(); - - expect(document.body.className).toContain(globalResizeCursorClassName); - }); - }); - - describe('#removeGlobalResizeCursorStyleFromBody', () => { - test('it removes the global-resize-cursor style from body', () => { - mount( - - } - height="100%" - id="test" - onResize={jest.fn()} - render={() => <>} - /> - - ); - - addGlobalResizeCursorStyleToBody(); - removeGlobalResizeCursorStyleFromBody(); - - expect(document.body.className).not.toContain(globalResizeCursorClassName); - }); - }); - - describe('#calculateDeltaX', () => { - test('it returns 0 when prevX isEqual 0', () => { - expect(calculateDeltaX({ prevX: 0, screenX: 189 })).toEqual(0); - }); - - test('it returns positive difference when screenX > prevX', () => { - expect(calculateDeltaX({ prevX: 10, screenX: 189 })).toEqual(179); - }); - - test('it returns negative difference when prevX > screenX ', () => { - expect(calculateDeltaX({ prevX: 199, screenX: 189 })).toEqual(-10); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx b/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx deleted file mode 100644 index eb3326c2f2cd0..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx +++ /dev/null @@ -1,145 +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, { useEffect, useRef } from 'react'; -import { fromEvent, Observable, Subscription } from 'rxjs'; -import { concatMap, takeUntil } from 'rxjs/operators'; -import styled from 'styled-components'; - -export type OnResize = ({ delta, id }: { delta: number; id: string }) => void; - -export const resizeCursorStyle = 'col-resize'; -export const globalResizeCursorClassName = 'global-resize-cursor'; - -/** This polyfill is for Safari and IE-11 only. `movementX` is more accurate and "feels" better, so only use this function on Safari and IE-11 */ -export const calculateDeltaX = ({ prevX, screenX }: { prevX: number; screenX: number }) => - prevX !== 0 ? screenX - prevX : 0; - -const isSafari = /^((?!chrome|android|crios|fxios|Firefox).)*safari/i.test(navigator.userAgent); - -interface ResizeHandleContainerProps { - bottom?: string | number; - /** optionally provide a height style ResizeHandleContainer */ - height?: string; - left?: string | number; - positionAbsolute?: boolean; - right?: string | number; - top?: string | number; -} - -interface Props extends ResizeHandleContainerProps { - /** a (styled) resize handle */ - handle: React.ReactNode; - /** the `onResize` callback will be invoked with this id */ - id: string; - /** invoked when the handle is resized */ - onResize: OnResize; - /** The resizeable content to render */ - render: (isResizing: boolean) => React.ReactNode; -} - -const ResizeHandleContainer = styled.div` - bottom: ${({ positionAbsolute, bottom }) => positionAbsolute && bottom}; - cursor: ${resizeCursorStyle}; - height: ${({ height }) => height}; - left: ${({ positionAbsolute, left }) => positionAbsolute && left}; - position: ${({ positionAbsolute }) => positionAbsolute && 'absolute'}; - right: ${({ positionAbsolute, right }) => positionAbsolute && right}; - top: ${({ positionAbsolute, top }) => positionAbsolute && top}; - z-index: ${({ positionAbsolute, theme }) => positionAbsolute && theme.eui.euiZLevel1}; -`; -ResizeHandleContainer.displayName = 'ResizeHandleContainer'; - -export const addGlobalResizeCursorStyleToBody = () => { - document.body.classList.add(globalResizeCursorClassName); -}; - -export const removeGlobalResizeCursorStyleFromBody = () => { - document.body.classList.remove(globalResizeCursorClassName); -}; - -export const Resizeable = React.memo( - ({ bottom, handle, height, id, left, onResize, positionAbsolute, render, right, top }) => { - const drag$ = useRef | null>(null); - const dragEventTargets = useRef>([]); - const dragSubscription = useRef(null); - const prevX = useRef(0); - const ref = useRef(null); - const upSubscription = useRef(null); - const isResizingRef = useRef(false); - - const calculateDelta = (e: MouseEvent) => { - const deltaX = calculateDeltaX({ prevX: prevX.current, screenX: e.screenX }); - prevX.current = e.screenX; - return deltaX; - }; - useEffect(() => { - const move$ = fromEvent(document, 'mousemove'); - const down$ = fromEvent(ref.current!, 'mousedown'); - const up$ = fromEvent(document, 'mouseup'); - - drag$.current = down$.pipe(concatMap(() => move$.pipe(takeUntil(up$)))); - dragSubscription.current = - drag$.current && - drag$.current.subscribe(event => { - // We do a feature detection of event.movementX here and if it is missing - // we calculate the delta manually. Browsers IE-11 and Safari will call calculateDelta - const delta = - event.movementX == null || isSafari ? calculateDelta(event) : event.movementX; - if (!isResizingRef.current) { - isResizingRef.current = true; - } - onResize({ id, delta }); - if (event.target != null && event.target instanceof HTMLElement) { - const htmlElement: HTMLElement = event.target; - dragEventTargets.current = [ - ...dragEventTargets.current, - { htmlElement, prevCursor: htmlElement.style.cursor }, - ]; - htmlElement.style.cursor = resizeCursorStyle; - } - }); - - upSubscription.current = up$.subscribe(() => { - if (isResizingRef.current) { - dragEventTargets.current.reverse().forEach(eventTarget => { - eventTarget.htmlElement.style.cursor = eventTarget.prevCursor; - }); - dragEventTargets.current = []; - isResizingRef.current = false; - } - }); - return () => { - if (dragSubscription.current != null) { - dragSubscription.current.unsubscribe(); - } - if (upSubscription.current != null) { - upSubscription.current.unsubscribe(); - } - }; - }, []); - - return ( - <> - {render(isResizingRef.current)} - - {handle} - - - ); - } -); - -Resizeable.displayName = 'Resizeable'; diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/is_resizing.tsx b/x-pack/legacy/plugins/siem/public/components/resize_handle/is_resizing.tsx deleted file mode 100644 index 5eb2d397b4c98..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/is_resizing.tsx +++ /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. - */ - -import { useState } from 'react'; - -export const useIsContainerResizing = () => { - const [isResizing, setIsResizing] = useState(false); - - return { - isResizing, - setIsResizing, - }; -}; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 1b66a130c3550..dfea99ffd7091 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -1,20 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` - - - + - - + - - - - + + + - - + + `; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/column_header.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/column_header.tsx index 15911f522032a..ccaeeff972a81 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/column_header.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/column_header.tsx @@ -4,6 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; +import { Draggable } from 'react-beautiful-dnd'; +import { Resizable, ResizeCallback } from 're-resizable'; +import { DragEffects } from '../../../drag_and_drop/draggable_wrapper'; +import { getDraggableFieldId, DRAG_TYPE_FIELD } from '../../../drag_and_drop/helpers'; +import { DraggableFieldBadge } from '../../../draggables/field_badge'; +import { OnColumnRemoved, OnColumnSorted, OnFilterChange, OnColumnResized } from '../../events'; +import { EventsTh, EventsThContent, EventsHeadingHandle } from '../../styles'; +import { Sort } from '../sort'; +import { DraggingContainer } from './common/dragging_container'; + +import { Header } from './header'; import { ColumnId } from '../column_id'; export type ColumnHeaderType = 'not-filtered' | 'text-filter'; @@ -22,3 +34,90 @@ export interface ColumnHeader { type?: string; width: number; } + +interface ColumneHeaderProps { + draggableIndex: number; + header: ColumnHeader; + onColumnRemoved: OnColumnRemoved; + onColumnSorted: OnColumnSorted; + onColumnResized: OnColumnResized; + onFilterChange?: OnFilterChange; + sort: Sort; + timelineId: string; +} + +const ColumnHeaderComponent: React.FC = ({ + draggableIndex, + header, + timelineId, + onColumnRemoved, + onColumnResized, + onColumnSorted, + onFilterChange, + sort, +}) => { + const [isDragging, setIsDragging] = React.useState(false); + const handleResizeStop: ResizeCallback = (e, direction, ref, delta) => { + onColumnResized({ columnId: header.id, delta: delta.width }); + }; + + return ( + , + }} + onResizeStop={handleResizeStop} + > + + {(dragProvided, dragSnapshot) => ( + + {!dragSnapshot.isDragging ? ( + +
+ + ) : ( + + + + + + )} + + )} + + + ); +}; + +export const ColumnHeader = React.memo(ColumnHeaderComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/common/dragging_container.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/common/dragging_container.tsx new file mode 100644 index 0000000000000..21aa17aa1c52c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/common/dragging_container.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FC, memo, useEffect } from 'react'; + +interface DraggingContainerProps { + children: JSX.Element; + onDragging: Function; +} + +const DraggingContainerComponent: FC = ({ children, onDragging }) => { + useEffect(() => { + onDragging(true); + + return () => onDragging(false); + }); + + return children; +}; + +export const DraggingContainer = memo(DraggingContainerComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap index 64c2b6ed10692..d30054ae1a3fe 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap @@ -1,14 +1,50 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Header renders correctly against snapshot 1`] = ` -} - id="@timestamp" - onResize={[Function]} - positionAbsolute={true} - render={[Function]} - right="-1px" - top={0} -/> + + + + + + `; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx new file mode 100644 index 0000000000000..c38ae26050c93 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/header_content.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiToolTip } from '@elastic/eui'; +import { noop } from 'lodash/fp'; +import React from 'react'; + +import { TruncatableText } from '../../../../truncatable_text'; +import { EventsHeading, EventsHeadingTitleButton, EventsHeadingTitleSpan } from '../../../styles'; +import { useTimelineContext } from '../../../timeline_context'; +import { Sort } from '../../sort'; +import { SortIndicator } from '../../sort/sort_indicator'; +import { ColumnHeader } from '../column_header'; +import { HeaderToolTipContent } from '../header_tooltip_content'; +import { getSortDirection } from './helpers'; + +interface HeaderContentProps { + children: React.ReactNode; + header: ColumnHeader; + isResizing: boolean; + onClick: () => void; + sort: Sort; +} + +const HeaderContentComponent: React.FC = ({ + children, + header, + isResizing, + onClick, + sort, +}) => { + const isLoading = useTimelineContext(); + + return ( + + {header.aggregatable ? ( + + + } + > + <>{header.label ?? header.id} + + + + + + ) : ( + + + } + > + <>{header.label ?? header.id} + + + + )} + + {children} + + ); +}; + +export const HeaderContent = React.memo(HeaderContentComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx index 64f32674cd042..fab2e7ee872bf 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx @@ -32,9 +32,7 @@ describe('Header', () => { @@ -49,9 +47,7 @@ describe('Header', () => { @@ -74,9 +70,7 @@ describe('Header', () => { @@ -98,9 +92,7 @@ describe('Header', () => { @@ -126,9 +118,7 @@ describe('Header', () => { @@ -153,9 +143,7 @@ describe('Header', () => { @@ -181,9 +169,7 @@ describe('Header', () => { @@ -201,9 +187,7 @@ describe('Header', () => { @@ -221,9 +205,7 @@ describe('Header', () => { @@ -334,9 +316,7 @@ describe('Header', () => { @@ -357,9 +337,7 @@ describe('Header', () => { @@ -369,25 +347,4 @@ describe('Header', () => { expect(wrapper.find('[data-test-subj="header-tooltip"]').exists()).toEqual(true); }); }); - - describe('setIsResizing', () => { - test('setIsResizing have been call when it renders actions', () => { - const mockSetIsResizing = jest.fn(); - mount( - - - - ); - - expect(mockSetIsResizing).toHaveBeenCalled(); - }); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx index 311b4bfda60fe..c45b9ce425deb 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx @@ -4,103 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiToolTip } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React from 'react'; +import React, { useCallback } from 'react'; -import { OnResize, Resizeable } from '../../../../resize_handle'; -import { TruncatableText } from '../../../../truncatable_text'; -import { OnColumnRemoved, OnColumnResized, OnColumnSorted, OnFilterChange } from '../../../events'; -import { - EventsHeading, - EventsHeadingHandle, - EventsHeadingTitleButton, - EventsHeadingTitleSpan, -} from '../../../styles'; -import { useTimelineContext } from '../../../timeline_context'; +import { OnColumnRemoved, OnColumnSorted, OnFilterChange } from '../../../events'; import { Sort } from '../../sort'; -import { SortIndicator } from '../../sort/sort_indicator'; import { Actions } from '../actions'; import { ColumnHeader } from '../column_header'; import { Filter } from '../filter'; -import { HeaderToolTipContent } from '../header_tooltip_content'; -import { getNewSortDirectionOnClick, getSortDirection } from './helpers'; - -interface HeaderCompProps { - children: React.ReactNode; - header: ColumnHeader; - isResizing: boolean; - onClick: () => void; - sort: Sort; -} - -const HeaderComp = React.memo( - ({ children, header, isResizing, onClick, sort }) => { - const isLoading = useTimelineContext(); - - return ( - - {header.aggregatable ? ( - - - } - > - <>{header.label ?? header.id} - - - - - - ) : ( - - - } - > - <>{header.label ?? header.id} - - - - )} - - {children} - - ); - } -); -HeaderComp.displayName = 'HeaderComp'; +import { getNewSortDirectionOnClick } from './helpers'; +import { HeaderContent } from './header_content'; interface Props { header: ColumnHeader; onColumnRemoved: OnColumnRemoved; - onColumnResized: OnColumnResized; onColumnSorted: OnColumnSorted; onFilterChange?: OnFilterChange; - setIsResizing: (isResizing: boolean) => void; sort: Sort; timelineId: string; } -/** Renders a header */ -export const HeaderComponent = ({ +export const HeaderComponent: React.FC = ({ header, onColumnRemoved, - onColumnResized, onColumnSorted, onFilterChange = noop, - setIsResizing, sort, -}: Props) => { - const onClick = () => { +}) => { + const onClick = useCallback(() => { onColumnSorted!({ columnId: header.id, sortDirection: getNewSortDirectionOnClick({ @@ -108,41 +39,17 @@ export const HeaderComponent = ({ currentSort: sort, }), }); - }; - - const onResize: OnResize = ({ delta, id }) => { - onColumnResized({ columnId: id, delta }); - }; - - const renderActions = (isResizing: boolean) => { - setIsResizing(isResizing); - return ( - <> - - - - - - - ); - }; + }, [onColumnSorted, header, sort]); return ( - } - id={header.id} - onResize={onResize} - positionAbsolute - render={renderActions} - right="-1px" - top={0} - /> + <> + + + + + + ); }; -HeaderComponent.displayName = 'HeaderComponent'; - export const Header = React.memo(HeaderComponent); - -Header.displayName = 'Header'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.test.tsx index 0fdd7d78ae253..4b97dd7573a45 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.test.tsx @@ -17,14 +17,6 @@ import { useMountAppended } from '../../../../utils/use_mount_appended'; import { ColumnHeadersComponent } from '.'; -jest.mock('../../../resize_handle/is_resizing', () => ({ - ...jest.requireActual('../../../resize_handle/is_resizing'), - useIsContainerResizing: () => ({ - isResizing: true, - setIsResizing: jest.fn(), - }), -})); - describe('ColumnHeaders', () => { const mount = useMountAppended(); @@ -117,37 +109,5 @@ describe('ColumnHeaders', () => { ).toContain(h.id); }); }); - - test('it disables dragging during a column resize', () => { - const wrapper = mount( - - - - ); - - defaultHeaders.forEach(h => { - expect( - wrapper - .find('[data-test-subj="draggable"]') - .first() - .prop('isDragDisabled') - ).toBe(true); - }); - }); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx index 52495c2e3c816..953ffb4d4932b 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx @@ -7,19 +7,12 @@ import { EuiCheckbox } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React from 'react'; -import { Draggable, Droppable } from 'react-beautiful-dnd'; +import { Droppable } from 'react-beautiful-dnd'; import { BrowserFields } from '../../../../containers/source'; -import { DragEffects } from '../../../drag_and_drop/draggable_wrapper'; -import { - DRAG_TYPE_FIELD, - droppableTimelineColumnsPrefix, - getDraggableFieldId, -} from '../../../drag_and_drop/helpers'; -import { DraggableFieldBadge } from '../../../draggables/field_badge'; +import { DRAG_TYPE_FIELD, droppableTimelineColumnsPrefix } from '../../../drag_and_drop/helpers'; import { StatefulFieldsBrowser } from '../../../fields_browser'; import { FIELD_BROWSER_HEIGHT, FIELD_BROWSER_WIDTH } from '../../../fields_browser/helpers'; -import { useIsContainerResizing } from '../../../resize_handle/is_resizing'; import { OnColumnRemoved, OnColumnResized, @@ -39,7 +32,6 @@ import { import { Sort } from '../sort'; import { ColumnHeader } from './column_header'; import { EventsSelect } from './events_select'; -import { Header } from './header'; interface Props { actionsColumnWidth: number; @@ -78,132 +70,86 @@ export const ColumnHeadersComponent = ({ sort, timelineId, toggleColumn, -}: Props) => { - const { isResizing, setIsResizing } = useIsContainerResizing(); - - return ( - - - - {showEventsSelect && ( - - - - - - )} - - {showSelectAllCheckbox && ( - - - ) => { - onSelectAll({ isSelected: event.currentTarget.checked }); - }} - /> - - - )} - +}: Props) => ( + + + + {showEventsSelect && ( - - + + + + )} + {showSelectAllCheckbox && ( + + + ) => { + onSelectAll({ isSelected: event.currentTarget.checked }); + }} /> - + )} + + + + + + - - {dropProvided => ( + + {(dropProvided, snapshot) => ( + <> - {columnHeaders.map((header, i) => ( - ( + - {(dragProvided, dragSnapshot) => ( - - {!dragSnapshot.isDragging ? ( - -
- - ) : ( - - - - )} - - )} - + draggableIndex={draggableIndex} + timelineId={timelineId} + header={header} + onColumnRemoved={onColumnRemoved} + onColumnSorted={onColumnSorted} + onFilterChange={onFilterChange} + onColumnResized={onColumnResized} + sort={sort} + /> ))} - )} - - - - ); -}; - -ColumnHeadersComponent.displayName = 'ColumnHeadersComponent'; + {dropProvided.placeholder} + + )} + + + +); export const ColumnHeaders = React.memo(ColumnHeadersComponent); - -ColumnHeaders.displayName = 'ColumnHeaders'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap index a168f8d48fa33..75c05dd1455af 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap @@ -1,10 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Columns it renders the expected columns 1`] = ` - - - - - - + + - - - - + + - - - - + + - - - - + + - - - - + + - - - - + + - - - - + + + `; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx index f0f41fc1f674f..b6fdc1b2973aa 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/styles.tsx @@ -27,7 +27,7 @@ export const TimelineBodyGlobalStyle = createGlobalStyle` } `; -export const TimelineBody = styled.div.attrs(({ className }) => ({ +export const TimelineBody = styled.div.attrs(({ className = '' }) => ({ className: `siemTimeline__body ${className}`, }))<{ bodyHeight: number }>` height: ${({ bodyHeight }) => `${bodyHeight}px`}; @@ -56,15 +56,14 @@ TimelineBody.displayName = 'TimelineBody'; * EVENTS TABLE */ -export const EventsTable = styled.div.attrs(({ className }) => ({ +export const EventsTable = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable ${className}`, role: 'table', }))``; -EventsTable.displayName = 'EventsTable'; /* EVENTS HEAD */ -export const EventsThead = styled.div.attrs(({ className }) => ({ +export const EventsThead = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thead ${className}`, role: 'rowgroup', }))` @@ -75,7 +74,6 @@ export const EventsThead = styled.div.attrs(({ className }) => ({ top: 0; z-index: ${({ theme }) => theme.eui.euiZLevel1}; `; -EventsThead.displayName = 'EventsThead'; export const EventsTrHeader = styled.div.attrs(({ className }) => ({ className: `siemEventsTable__trHeader ${className}`, @@ -83,9 +81,8 @@ export const EventsTrHeader = styled.div.attrs(({ className }) => ({ }))` display: flex; `; -EventsTrHeader.displayName = 'EventsTrHeader'; -export const EventsThGroupActions = styled.div.attrs(({ className }) => ({ +export const EventsThGroupActions = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thGroupActions ${className}`, }))<{ actionsColumnWidth: number; justifyContent: string }>` display: flex; @@ -93,24 +90,27 @@ export const EventsThGroupActions = styled.div.attrs(({ className }) => ({ justify-content: ${({ justifyContent }) => justifyContent}; min-width: 0; `; -EventsThGroupActions.displayName = 'EventsThGroupActions'; -export const EventsThGroupData = styled.div.attrs(({ className }) => ({ +export const EventsThGroupData = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thGroupData ${className}`, -}))` +}))<{ isDragging?: boolean }>` display: flex; + + > div:hover .siemEventsHeading__handle { + display: ${({ isDragging }) => (isDragging ? 'none' : 'block')}; + opacity: 1; + visibility: visible; + } `; -EventsThGroupData.displayName = 'EventsThGroupData'; -export const EventsTh = styled.div.attrs(({ className }) => ({ +export const EventsTh = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__th ${className}`, role: 'columnheader', -}))<{ isDragging?: boolean; position?: string }>` +}))` align-items: center; display: flex; flex-shrink: 0; min-width: 0; - position: ${({ position }) => position}; .siemEventsTable__thGroupActions &:first-child:last-child { flex: 1; @@ -121,10 +121,18 @@ export const EventsTh = styled.div.attrs(({ className }) => ({ cursor: move; /* Fallback for IE11 */ cursor: grab; } + + > div:focus { + outline: 0; /* disable focus on Resizable element */ + } + + /* don't display Draggable placeholder */ + [data-rbd-placeholder-context-id] { + display: none !important; + } `; -EventsTh.displayName = 'EventsTh'; -export const EventsThContent = styled.div.attrs(({ className }) => ({ +export const EventsThContent = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__thContent ${className}`, }))<{ textAlign?: string }>` font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; @@ -135,19 +143,17 @@ export const EventsThContent = styled.div.attrs(({ className }) => ({ text-align: ${({ textAlign }) => textAlign}; width: 100%; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ `; -EventsThContent.displayName = 'EventsThContent'; /* EVENTS BODY */ -export const EventsTbody = styled.div.attrs(({ className }) => ({ +export const EventsTbody = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__tbody ${className}`, role: 'rowgroup', }))` overflow-x: hidden; `; -EventsTbody.displayName = 'EventsTbody'; -export const EventsTrGroup = styled.div.attrs(({ className }) => ({ +export const EventsTrGroup = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__trGroup ${className}`, }))<{ className?: string }>` border-bottom: ${({ theme }) => theme.eui.euiBorderWidthThin} solid @@ -157,17 +163,15 @@ export const EventsTrGroup = styled.div.attrs(({ className }) => ({ background-color: ${({ theme }) => theme.eui.euiTableHoverColor}; } `; -EventsTrGroup.displayName = 'EventsTrGroup'; -export const EventsTrData = styled.div.attrs(({ className }) => ({ +export const EventsTrData = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__trData ${className}`, role: 'row', }))` display: flex; `; -EventsTrData.displayName = 'EventsTrData'; -export const EventsTrSupplement = styled.div.attrs(({ className }) => ({ +export const EventsTrSupplement = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__trSupplement ${className}`, }))<{ className: string }>` font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; @@ -175,9 +179,8 @@ export const EventsTrSupplement = styled.div.attrs(({ className }) => ({ padding: 0 ${({ theme }) => theme.eui.paddingSizes.xs} 0 ${({ theme }) => theme.eui.paddingSizes.xl}; `; -EventsTrSupplement.displayName = 'EventsTrSupplement'; -export const EventsTdGroupActions = styled.div.attrs(({ className }) => ({ +export const EventsTdGroupActions = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__tdGroupActions ${className}`, }))<{ actionsColumnWidth: number }>` display: flex; @@ -185,16 +188,14 @@ export const EventsTdGroupActions = styled.div.attrs(({ className }) => ({ flex: 0 0 ${({ actionsColumnWidth }) => `${actionsColumnWidth}px`}; min-width: 0; `; -EventsTdGroupActions.displayName = 'EventsTdGroupActions'; -export const EventsTdGroupData = styled.div.attrs(({ className }) => ({ +export const EventsTdGroupData = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__tdGroupData ${className}`, }))` display: flex; `; -EventsTdGroupData.displayName = 'EventsTdGroupData'; -export const EventsTd = styled.div.attrs(({ className }) => ({ +export const EventsTd = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__td ${className}`, role: 'cell', }))` @@ -207,7 +208,6 @@ export const EventsTd = styled.div.attrs(({ className }) => ({ flex: 1; } `; -EventsTd.displayName = 'EventsTd'; export const EventsTdContent = styled.div.attrs(({ className }) => ({ className: `siemEventsTable__tdContent ${className}`, @@ -219,13 +219,12 @@ export const EventsTdContent = styled.div.attrs(({ className }) => ({ text-align: ${({ textAlign }) => textAlign}; width: 100%; /* Using width: 100% instead of flex: 1 and max-width: 100% for IE11 */ `; -EventsTdContent.displayName = 'EventsTdContent'; /** * EVENTS HEADING */ -export const EventsHeading = styled.div.attrs(({ className }) => ({ +export const EventsHeading = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsHeading ${className}`, }))<{ isLoading: boolean }>` align-items: center; @@ -235,9 +234,8 @@ export const EventsHeading = styled.div.attrs(({ className }) => ({ cursor: ${({ isLoading }) => (isLoading ? 'wait' : 'grab')}; } `; -EventsHeading.displayName = 'EventsHeading'; -export const EventsHeadingTitleButton = styled.button.attrs(({ className }) => ({ +export const EventsHeadingTitleButton = styled.button.attrs(({ className = '' }) => ({ className: `siemEventsHeading__title siemEventsHeading__title--aggregatable ${className}`, type: 'button', }))` @@ -260,16 +258,14 @@ export const EventsHeadingTitleButton = styled.button.attrs(({ className }) => ( margin-left: ${({ theme }) => theme.eui.euiSizeXS}; } `; -EventsHeadingTitleButton.displayName = 'EventsHeadingTitleButton'; export const EventsHeadingTitleSpan = styled.span.attrs(({ className }) => ({ className: `siemEventsHeading__title siemEventsHeading__title--notAggregatable ${className}`, }))` min-width: 0; `; -EventsHeadingTitleSpan.displayName = 'EventsHeadingTitleSpan'; -export const EventsHeadingExtra = styled.div.attrs(({ className }) => ({ +export const EventsHeadingExtra = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsHeading__extra ${className}`, }))` margin-left: auto; @@ -285,9 +281,8 @@ export const EventsHeadingExtra = styled.div.attrs(({ className }) => ({ } } `; -EventsHeadingExtra.displayName = 'EventsHeadingExtra'; -export const EventsHeadingHandle = styled.div.attrs(({ className }) => ({ +export const EventsHeadingHandle = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsHeading__handle ${className}`, }))` background-color: ${({ theme }) => theme.eui.euiBorderColor}; @@ -297,17 +292,11 @@ export const EventsHeadingHandle = styled.div.attrs(({ className }) => ({ visibility: hidden; width: ${({ theme }) => theme.eui.euiBorderWidthThick}; - .siemEventsTable__thead:hover & { - opacity: 1; - visibility: visible; - } - &:hover { background-color: ${({ theme }) => theme.eui.euiColorPrimary}; cursor: col-resize; } `; -EventsHeadingHandle.displayName = 'EventsHeadingHandle'; /** * EVENTS LOADING diff --git a/x-pack/package.json b/x-pack/package.json index d513e4ed34965..ffa593f5728ee 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -321,6 +321,7 @@ "request": "^2.88.0", "reselect": "3.0.1", "resize-observer-polyfill": "^1.5.0", + "re-resizable": "^6.1.1", "rison-node": "0.3.1", "rxjs": "^6.5.3", "semver": "5.7.0", diff --git a/yarn.lock b/yarn.lock index 9631ca271295e..96ec5213badcb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4003,10 +4003,10 @@ dependencies: "@types/react" "*" -"@types/react-beautiful-dnd@^11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-11.0.3.tgz#51d9f37942dd18cc4aa10da98a5c883664e7ee46" - integrity sha512-7ZbT/7mNJu+uRrUGdTQ1hAINtqg909L4NHrXyspV42fvVgBgda6ysiBzoDUMENmQ/RlRJdpyrcp8Dtd/77bp9Q== +"@types/react-beautiful-dnd@^11.0.4": + version "11.0.4" + resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-11.0.4.tgz#25cdf16864df8fd1d82f9416c8c0fd957e793024" + integrity sha512-a1Nvt1AcSEA962OuXrk1gu5bJQhzu0B3qFNO999/0nmF+oAD7HIAY0DwraS3L3XM1cVuRO1+PtpTkD4CfRK2QA== dependencies: "@types/react" "*" @@ -12374,6 +12374,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.4, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-memoize@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.1.tgz#c3519241e80552ce395e1a32dcdde8d1fd680f5d" + integrity sha512-xdmw296PCL01tMOXx9mdJSmWY29jQgxyuZdq0rEHMu+Tpe1eOEtCycoG6chzlcrWsNgpZP7oL8RiQr7+G6Bl6g== + fast-safe-stringify@^2.0.4: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz#04b26106cc56681f51a044cfc0d76cf0008ac2c2" @@ -22957,6 +22962,13 @@ re-reselect@^3.4.0: resolved "https://registry.yarnpkg.com/re-reselect/-/re-reselect-3.4.0.tgz#0f2303f3c84394f57f0cd31fea08a1ca4840a7cd" integrity sha512-JsecfN+JlckncVXTWFWjn0Vk6uInl8GSf4eEd9tTk5qXHlgqkPdILpnYpgZcISXNYAzvfvsCZviaDk8AxyS5sg== +re-resizable@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.1.1.tgz#7ff7cfe92c0b9d8b0bceaa578aadaeeff8931eaf" + integrity sha512-ngzX5xbXi9LlIghJUYZaBDkJUIMLYqO3tQ2cJZoNprCRGhfHnbyufKm51MZRIOBlLigLzPPFKBxQE8ZLezKGfA== + dependencies: + fast-memoize "^2.5.1" + react-ace@^5.5.0: version "5.10.0" resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-5.10.0.tgz#e328b37ac52759f700be5afdb86ada2f5ec84c5e" From 1ffd30eb859a679fa15ade5cad07258ce1523bb3 Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Wed, 8 Jan 2020 08:08:51 -0800 Subject: [PATCH 13/63] [DOCS][Spaces] Adds example of of using default route setting (#54201) * Added defaultRoute examples Added `Examples: /app/monitoring, /app/ml, /app/kibana#/dashboards` * [DOCS] Adds default route example to Spaces docs Co-authored-by: ErnestoBezanilla --- .../images/spaces-configure-landing-page.png | Bin 0 -> 78322 bytes docs/spaces/index.asciidoc | 65 ++++++++++-------- 2 files changed, 35 insertions(+), 30 deletions(-) create mode 100644 docs/spaces/images/spaces-configure-landing-page.png diff --git a/docs/spaces/images/spaces-configure-landing-page.png b/docs/spaces/images/spaces-configure-landing-page.png new file mode 100644 index 0000000000000000000000000000000000000000..15006594b6d7bcb6c217273c0be6fd7850019dc5 GIT binary patch literal 78322 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

&il!C#FP$BNF%1gJ7jbgQo)#rudtbpj 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 literal 0 HcmV?d00001 diff --git a/docs/spaces/index.asciidoc b/docs/spaces/index.asciidoc index 69655aac521e7..fb5ef670692dc 100644 --- a/docs/spaces/index.asciidoc +++ b/docs/spaces/index.asciidoc @@ -2,13 +2,13 @@ [[xpack-spaces]] == Spaces -Spaces enable you to organize your dashboards and other saved -objects into meaningful categories. Once inside a space, you see only -the dashboards and saved objects that belong to that space. +Spaces enable you to organize your dashboards and other saved +objects into meaningful categories. Once inside a space, you see only +the dashboards and saved objects that belong to that space. -{kib} creates a default space for you. -After you create your own -spaces, you're asked to choose a space when you log in to Kibana. You can change your +{kib} creates a default space for you. +After you create your own +spaces, you're asked to choose a space when you log in to Kibana. You can change your current space at any time by using the menu in the upper left. [role="screenshot"] @@ -29,24 +29,24 @@ Kibana supports spaces in several ways. You can: [[spaces-managing]] === View, create, and delete spaces -Go to **Management > Spaces** for an overview of your spaces. This view provides actions +Go to **Management > Spaces** for an overview of your spaces. This view provides actions for you to create, edit, and delete spaces. [role="screenshot"] image::spaces/images/space-management.png["Space management"] [float] -==== Create or edit a space +==== Create or edit a space -You can create as many spaces as you like. Click *Create a space* and provide a name, -URL identifier, optional description. +You can create as many spaces as you like. Click *Create a space* and provide a name, +URL identifier, optional description. -The URL identifier is a short text string that becomes part of the -{kib} URL when you are inside that space. {kib} suggests a URL identifier based +The URL identifier is a short text string that becomes part of the +{kib} URL when you are inside that space. {kib} suggests a URL identifier based on the name of your space, but you can customize the identifier to your liking. You cannot change the space identifier once you create the space. -{kib} also has an <> +{kib} also has an <> if you prefer to create spaces programatically. [role="screenshot"] @@ -55,7 +55,7 @@ image::spaces/images/edit-space.png["Space management"] [float] ==== Delete a space -Deleting a space permanently removes the space and all of its contents. +Deleting a space permanently removes the space and all of its contents. Find the space on the *Spaces* overview page and click the trash icon in the Actions column. You can't delete the default space, but you can customize it to your liking. @@ -63,14 +63,14 @@ You can't delete the default space, but you can customize it to your liking. [[spaces-control-feature-visibility]] === Control feature access based on user needs -You have control over which features are visible in each space. -For example, you might hide Dev Tools +You have control over which features are visible in each space. +For example, you might hide Dev Tools in your "Executive" space or show Stack Monitoring only in your "Admin" space. You can define which features to show or hide when you add or edit a space. -Controlling feature -visibility is not a security feature. To secure access -to specific features on a per-user basis, you must configure +Controlling feature +visibility is not a security feature. To secure access +to specific features on a per-user basis, you must configure <>. [role="screenshot"] @@ -80,10 +80,10 @@ image::spaces/images/edit-space-feature-visibility.png["Controlling features vis [[spaces-control-user-access]] === Control feature access based on user privileges -When using Kibana with security, you can configure applications and features -based on your users’ privileges. This means different roles can have access -to different features in the same space. -Power users might have privileges to create and edit visualizations and dashboards, +When using Kibana with security, you can configure applications and features +based on your users’ privileges. This means different roles can have access +to different features in the same space. +Power users might have privileges to create and edit visualizations and dashboards, while analysts or executives might have Dashboard and Canvas with read-only privileges. See <> for details. @@ -106,7 +106,7 @@ interface. . Import your saved objects. . (Optional) Delete objects in the export space that you no longer need. -{kib} also has beta <> and +{kib} also has beta <> and <> APIs if you want to automate this process. [float] @@ -115,17 +115,22 @@ interface. You can create a custom experience for users by configuring the {kib} landing page on a per-space basis. The landing page can route users to a specific dashboard, application, or saved object as they enter each space. -To configure the landing page, use the `defaultRoute` setting in < Advanced settings>>. + +To configure the landing page, use the default route setting in < Advanced settings>>. +For example, you might set the default route to `/app/kibana#/dashboards`. + +[role="screenshot"] +image::spaces/images/spaces-configure-landing-page.png["Configure space-level landing page"] + [float] [[spaces-delete-started]] === Disable and version updates -Spaces are automatically enabled in {kib}. If you don't want use this feature, +Spaces are automatically enabled in {kib}. If you don't want use this feature, you can disable it -by setting `xpack.spaces.enabled` to `false` in your +by setting `xpack.spaces.enabled` to `false` in your `kibana.yml` configuration file. -If you are upgrading your -version of {kib}, the default space will contain all of your existing saved objects. - +If you are upgrading your +version of {kib}, the default space will contain all of your existing saved objects. From 513428af449188dda301110a4b962967466e23aa Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 8 Jan 2020 19:20:07 +0300 Subject: [PATCH 14/63] add `examples/` to no-restricted-path config (#54252) --- .eslintrc.js | 5 +++++ examples/demo_search/server/plugin.ts | 2 +- src/plugins/data/server/index.ts | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 8a9d4da6178e9..1b2459f196efd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -247,6 +247,7 @@ module.exports = { '!x-pack/test/**/*', '(src|x-pack)/plugins/**/(public|server)/**/*', 'src/core/(public|server)/**/*', + 'examples/**/*', ], from: [ 'src/core/public/**/*', @@ -283,11 +284,15 @@ module.exports = { 'x-pack/legacy/plugins/**/*', '!x-pack/legacy/plugins/*/server/**/*', '!x-pack/legacy/plugins/*/index.{js,ts,tsx}', + + 'examples/**/*', + '!examples/**/server/**/*', ], from: [ 'src/core/server', 'src/core/server/**/*', '(src|x-pack)/plugins/*/server/**/*', + 'examples/**/server/**/*', ], errorMessage: 'Server modules cannot be imported into client modules or shared modules.', diff --git a/examples/demo_search/server/plugin.ts b/examples/demo_search/server/plugin.ts index 23c82225563c8..653aa217717fa 100644 --- a/examples/demo_search/server/plugin.ts +++ b/examples/demo_search/server/plugin.ts @@ -18,7 +18,7 @@ */ import { Plugin, CoreSetup, PluginInitializerContext } from 'kibana/server'; -import { DataPluginSetup } from 'src/plugins/data/server/plugin'; +import { PluginSetup as DataPluginSetup } from 'src/plugins/data/server'; import { demoSearchStrategyProvider } from './demo_search_strategy'; import { DEMO_SEARCH_STRATEGY, IDemoRequest, IDemoResponse } from '../common'; diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 8194e9a6c847e..3cd088744a439 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -18,7 +18,7 @@ */ import { PluginInitializerContext } from '../../../core/server'; -import { DataServerPlugin } from './plugin'; +import { DataServerPlugin, DataPluginSetup } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new DataServerPlugin(initializerContext); @@ -93,4 +93,4 @@ export { getKbnTypeNames, } from '../common'; -export { DataServerPlugin as Plugin }; +export { DataServerPlugin as Plugin, DataPluginSetup as PluginSetup }; From bc640bdcbaf1e1b5bb6a01d8495d545a1a67b4de Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Wed, 8 Jan 2020 16:26:56 +0000 Subject: [PATCH 15/63] [Dashboard] Removing 100% as dshDashboardViewport height (#54263) --- .../public/embeddable/viewport/_dashboard_viewport.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss b/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss index b446f1e57a895..bb95840676969 100644 --- a/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss +++ b/src/plugins/dashboard_embeddable_container/public/embeddable/viewport/_dashboard_viewport.scss @@ -1,9 +1,7 @@ .dshDashboardViewport { - height: 100%; width: 100%; } .dshDashboardViewport-withMargins { width: 100%; - height: 100%; } From 26a4ec4117426ea8dc54193c58cf70c707ab3e03 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Wed, 8 Jan 2020 19:46:10 +0300 Subject: [PATCH 16/63] use correct type (#54244) --- src/legacy/server/kbn_server.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index 0af6dacee59c8..8da1b3b05fa76 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -129,7 +129,7 @@ export interface KibanaCore { plugins: PluginsSetup; }; startDeps: { - core: CoreSetup; + core: CoreStart; plugins: Record; }; logger: LoggerFactory; From 53d1c96a4a015c7a55f18bb32d50884f492d8ad3 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Wed, 8 Jan 2020 18:10:26 +0100 Subject: [PATCH 17/63] Remove non existing codeowners (#54274) * Remove non existing codeowners * Add TSVB to Kibana App codeowners --- .github/CODEOWNERS | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 18d60bce4b95e..a0a22446ba31d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,7 @@ /src/legacy/core_plugins/kibana/public/local_application_service/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/home/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/dev_tools/ @elastic/kibana-app +/src/legacy/core_plugins/metrics/ @elastic/kibana-app /src/plugins/home/ @elastic/kibana-app /src/plugins/kibana_legacy/ @elastic/kibana-app /src/plugins/timelion/ @elastic/kibana-app @@ -147,6 +148,3 @@ /x-pack/legacy/plugins/searchprofiler/ @elastic/es-ui /x-pack/legacy/plugins/snapshot_restore/ @elastic/es-ui /x-pack/legacy/plugins/watcher/ @elastic/es-ui - -# Kibana TSVB external contractors -/src/legacy/core_plugins/metrics/ @elastic/kibana-tsvb-external From 7ffe38569e48cbebfeb00bd084799d8aa48f38f2 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Wed, 8 Jan 2020 18:19:20 +0100 Subject: [PATCH 18/63] Fix Vega react eslint errors (#54259) --- .eslintrc.js | 6 ------ .../vis_type_vega/public/components/vega_actions_menu.tsx | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 1b2459f196efd..c43366abf0c3d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -113,12 +113,6 @@ module.exports = { 'react-hooks/exhaustive-deps': 'off', }, }, - { - files: ['src/legacy/core_plugins/vis_type_vega/**/*.{js,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, { files: ['src/legacy/ui/public/vis/**/*.{js,ts,tsx}'], rules: { diff --git a/src/legacy/core_plugins/vis_type_vega/public/components/vega_actions_menu.tsx b/src/legacy/core_plugins/vis_type_vega/public/components/vega_actions_menu.tsx index 71a88b47a8be3..3d7fda990b2ae 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/components/vega_actions_menu.tsx +++ b/src/legacy/core_plugins/vis_type_vega/public/components/vega_actions_menu.tsx @@ -34,12 +34,12 @@ function VegaActionsMenu({ formatHJson, formatJson }: VegaActionsMenuProps) { const onHJsonCLick = useCallback(() => { formatHJson(); setIsPopoverOpen(false); - }, [isPopoverOpen, formatHJson]); + }, [formatHJson]); const onJsonCLick = useCallback(() => { formatJson(); setIsPopoverOpen(false); - }, [isPopoverOpen, formatJson]); + }, [formatJson]); const closePopover = useCallback(() => setIsPopoverOpen(false), []); From fc948a0c8e13c3bbeaefe95b12cf233e3ed8ec31 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Wed, 8 Jan 2020 13:38:34 -0500 Subject: [PATCH 19/63] [ML] DF Analytics Classification: ensure confusion matrix can be fetched (#53629) * check depVar field type before adding keyword suffix for evaluate endpoint * update indexPattern type and use FIELD types * add keyword suffix if field type is keyword * keyword suffix added if depVar is of type keyword AND text --- .../data_frame_analytics/common/analytics.ts | 4 ++- .../evaluate_panel.tsx | 35 ++++++++++++++++++- .../services/new_job_capabilities_service.ts | 3 +- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 3dd98395ef701..ce832513c4adc 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -374,6 +374,7 @@ interface LoadEvalDataConfig { searchQuery?: ResultsSearchQuery; ignoreDefaultQuery?: boolean; jobType: ANALYSIS_CONFIG_TYPE; + requiresKeyword?: boolean; } export const loadEvalData = async ({ @@ -385,6 +386,7 @@ export const loadEvalData = async ({ searchQuery, ignoreDefaultQuery, jobType, + requiresKeyword, }: LoadEvalDataConfig) => { const results: LoadEvaluateResult = { success: false, eval: null, error: null }; const defaultPredictionField = `${dependentVariable}_prediction`; @@ -392,7 +394,7 @@ export const loadEvalData = async ({ predictionFieldName ? predictionFieldName : defaultPredictionField }`; - if (jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION) { + if (jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && requiresKeyword === true) { predictedField = `${predictedField}.keyword`; } diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index ddf52943c2feb..7bb6949db1a99 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -35,8 +35,13 @@ import { ResultsSearchQuery, ANALYSIS_CONFIG_TYPE, } from '../../../../common/analytics'; +import { IIndexPattern } from '../../../../../../../../../../../src/plugins/data/common/index_patterns'; +import { ES_FIELD_TYPES } from '../../../../../../../../../../../src/plugins/data/public'; import { LoadingPanel } from '../loading_panel'; import { getColumnData } from './column_data'; +import { useKibanaContext } from '../../../../../contexts/kibana'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { getIndexPatternIdFromName } from '../../../../../util/index_utils'; const defaultPanelWidth = 500; @@ -55,17 +60,19 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const [docsCount, setDocsCount] = useState(null); const [error, setError] = useState(null); const [panelWidth, setPanelWidth] = useState(defaultPanelWidth); - // Column visibility const [visibleColumns, setVisibleColumns] = useState(() => columns.map(({ id }: { id: string }) => id) ); + const kibanaContext = useKibanaContext(); const index = jobConfig.dest.index; + const sourceIndex = jobConfig.source.index[0]; const dependentVariable = getDependentVar(jobConfig.analysis); const predictionFieldName = getPredictionFieldName(jobConfig.analysis); // default is 'ml' const resultsField = jobConfig.dest.results_field; + let requiresKeyword = false; const loadData = async ({ isTrainingClause, @@ -76,6 +83,31 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) }) => { setIsLoading(true); + try { + const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; + const indexPattern: IIndexPattern = await kibanaContext.indexPatterns.get(indexPatternId); + + if (indexPattern !== undefined) { + await newJobCapsService.initializeFromIndexPattern(indexPattern, false, false); + // If dependent_variable is of type keyword and text .keyword suffix is required for evaluate endpoint + const { fields } = newJobCapsService; + const depVarFieldType = fields.find(field => field.name === dependentVariable)?.type; + + // If it's a keyword type - check if it has a corresponding text type + if (depVarFieldType !== undefined && depVarFieldType === ES_FIELD_TYPES.KEYWORD) { + const field = newJobCapsService.getFieldById(dependentVariable.replace(/\.keyword$/, '')); + requiresKeyword = field !== null && field.type === ES_FIELD_TYPES.TEXT; + } else if (depVarFieldType !== undefined && depVarFieldType === ES_FIELD_TYPES.TEXT) { + // If text, check if has corresponding keyword type + const field = newJobCapsService.getFieldById(`${dependentVariable}.keyword`); + requiresKeyword = field !== null && field.type === ES_FIELD_TYPES.KEYWORD; + } + } + } catch (e) { + // Additional error handling due to missing field type is handled by loadEvalData + console.error('Unable to load new field types', error); // eslint-disable-line no-console + } + const evalData = await loadEvalData({ isTraining: false, index, @@ -85,6 +117,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) searchQuery, ignoreDefaultQuery, jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, + requiresKeyword, }); const docsCountResp = await loadDocsCount({ diff --git a/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts b/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts index 9d5c33d6cfc5c..d78c9298c6073 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts @@ -14,6 +14,7 @@ import { } from '../../../common/types/fields'; import { ES_FIELD_TYPES, + IIndexPattern, IndexPattern, IndexPatternsContract, } from '../../../../../../../src/plugins/data/public'; @@ -89,7 +90,7 @@ class NewJobCapsService { } public async initializeFromIndexPattern( - indexPattern: IndexPattern, + indexPattern: IIndexPattern, includeEventRateField = true, removeTextFields = true ) { From 89e4daf5bd0d81b26caf3c1a87edad013ada639e Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Wed, 8 Jan 2020 14:16:31 -0500 Subject: [PATCH 20/63] [Canvas] Fixes bugs with autoplay and refresh (#53149) * Fixes bugs with autoplay and refresh * Fix typecheck Co-authored-by: Elastic Machine --- .../plugins/canvas/public/lib/app_state.ts | 2 +- .../__tests__/workpad_autoplay.test.ts | 145 ++++++++++++++++ .../__tests__/workpad_refresh.test.ts | 161 ++++++++++++++++++ ...orkpad_autoplay.js => workpad_autoplay.ts} | 33 ++-- ...{workpad_refresh.js => workpad_refresh.ts} | 36 +++- 5 files changed, 351 insertions(+), 26 deletions(-) create mode 100644 x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts create mode 100644 x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts rename x-pack/legacy/plugins/canvas/public/state/middleware/{workpad_autoplay.js => workpad_autoplay.ts} (77%) rename x-pack/legacy/plugins/canvas/public/state/middleware/{workpad_refresh.js => workpad_refresh.ts} (65%) diff --git a/x-pack/legacy/plugins/canvas/public/lib/app_state.ts b/x-pack/legacy/plugins/canvas/public/lib/app_state.ts index be0f76b170c70..955125b713140 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/app_state.ts +++ b/x-pack/legacy/plugins/canvas/public/lib/app_state.ts @@ -92,7 +92,7 @@ export function setFullscreen(payload: boolean) { } } -export function setAutoplayInterval(payload: string) { +export function setAutoplayInterval(payload: string | null) { const appState = getAppState(); const appValue = appState[AppStateKeys.AUTOPLAY_INTERVAL]; diff --git a/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts b/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts new file mode 100644 index 0000000000000..11ebdcdc51d4d --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_autoplay.test.ts @@ -0,0 +1,145 @@ +/* + * 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('../../../lib/app_state'); +jest.mock('../../../lib/router_provider'); + +import { workpadAutoplay } from '../workpad_autoplay'; +import { setAutoplayInterval } from '../../../lib/app_state'; +import { createTimeInterval } from '../../../lib/time_interval'; +// @ts-ignore Untyped local +import { routerProvider } from '../../../lib/router_provider'; + +const next = jest.fn(); +const dispatch = jest.fn(); +const getState = jest.fn(); +const routerMock = { navigateTo: jest.fn() }; +routerProvider.mockReturnValue(routerMock); + +const middleware = workpadAutoplay({ dispatch, getState })(next); + +const workpadState = { + persistent: { + workpad: { + id: 'workpad-id', + pages: ['page1', 'page2', 'page3'], + page: 0, + }, + }, +}; + +const autoplayState = { + ...workpadState, + transient: { + autoplay: { + inFlight: false, + enabled: true, + interval: 5000, + }, + fullscreen: true, + }, +}; + +const autoplayDisabledState = { + ...workpadState, + transient: { + autoplay: { + inFlight: false, + enabled: false, + interval: 5000, + }, + }, +}; + +const action = {}; + +describe('workpad autoplay middleware', () => { + beforeEach(() => { + dispatch.mockClear(); + jest.resetAllMocks(); + }); + + describe('app state', () => { + it('sets the app state to the interval from state when enabled', () => { + getState.mockReturnValue(autoplayState); + middleware(action); + + expect(setAutoplayInterval).toBeCalledWith( + createTimeInterval(autoplayState.transient.autoplay.interval) + ); + }); + + it('sets the app state to null when not enabled', () => { + getState.mockReturnValue(autoplayDisabledState); + middleware(action); + + expect(setAutoplayInterval).toBeCalledWith(null); + }); + }); + + describe('autoplay navigation', () => { + it('navigates forward after interval', () => { + jest.useFakeTimers(); + getState.mockReturnValue(autoplayState); + middleware(action); + + jest.advanceTimersByTime(autoplayState.transient.autoplay.interval + 1); + + expect(routerMock.navigateTo).toBeCalledWith('loadWorkpad', { + id: workpadState.persistent.workpad.id, + page: workpadState.persistent.workpad.page + 2, // (index + 1) + 1 more for 1 indexed page number + }); + + jest.useRealTimers(); + }); + + it('navigates from last page back to front', () => { + jest.useFakeTimers(); + const onLastPageState = { ...autoplayState }; + onLastPageState.persistent.workpad.page = onLastPageState.persistent.workpad.pages.length - 1; + + getState.mockReturnValue(autoplayState); + middleware(action); + + jest.advanceTimersByTime(autoplayState.transient.autoplay.interval + 1); + + expect(routerMock.navigateTo).toBeCalledWith('loadWorkpad', { + id: workpadState.persistent.workpad.id, + page: 1, + }); + + jest.useRealTimers(); + }); + + it('continues autoplaying', () => { + jest.useFakeTimers(); + getState.mockReturnValue(autoplayState); + middleware(action); + + jest.advanceTimersByTime(autoplayState.transient.autoplay.interval * 2 + 1); + expect(routerMock.navigateTo).toBeCalledTimes(2); + jest.useRealTimers(); + }); + + it('does not reset timer between middleware calls', () => { + jest.useFakeTimers(); + + getState.mockReturnValue(autoplayState); + middleware(action); + + // Advance until right before timeout + jest.advanceTimersByTime(autoplayState.transient.autoplay.interval - 1); + + // Run middleware again + middleware(action); + + // Advance timer + jest.advanceTimersByTime(1); + + expect(routerMock.navigateTo).toBeCalled(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts b/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts new file mode 100644 index 0000000000000..2123c9606f1f0 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/middleware/__tests__/workpad_refresh.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('../../../legacy'); +jest.mock('ui/new_platform'); // actions/elements has some dependencies on ui/new_platform. +jest.mock('../../../lib/app_state'); + +import { workpadRefresh } from '../workpad_refresh'; +import { inFlightComplete } from '../../actions/resolved_args'; +// @ts-ignore untyped local +import { setRefreshInterval } from '../../actions/workpad'; +import { setRefreshInterval as setAppStateRefreshInterval } from '../../../lib/app_state'; + +import { createTimeInterval } from '../../../lib/time_interval'; + +const next = jest.fn(); +const dispatch = jest.fn(); +const getState = jest.fn(); + +const middleware = workpadRefresh({ dispatch, getState })(next); + +const refreshState = { + transient: { + refresh: { + interval: 5000, + }, + }, +}; + +const noRefreshState = { + transient: { + refresh: { + interval: 0, + }, + }, +}; + +const inFlightState = { + transient: { + refresh: { + interval: 5000, + }, + inFlight: true, + }, +}; + +describe('workpad refresh middleware', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('onInflightComplete', () => { + it('refreshes if interval gt 0', () => { + jest.useFakeTimers(); + getState.mockReturnValue(refreshState); + + middleware(inFlightComplete()); + + jest.runAllTimers(); + + expect(dispatch).toHaveBeenCalled(); + }); + + it('does not reset interval if another action occurs', () => { + jest.useFakeTimers(); + getState.mockReturnValue(refreshState); + + middleware(inFlightComplete()); + + jest.advanceTimersByTime(refreshState.transient.refresh.interval - 1); + + expect(dispatch).not.toHaveBeenCalled(); + middleware(inFlightComplete()); + + jest.advanceTimersByTime(1); + + expect(dispatch).toHaveBeenCalled(); + }); + + it('does not refresh if interval is 0', () => { + jest.useFakeTimers(); + getState.mockReturnValue(noRefreshState); + + middleware(inFlightComplete()); + + jest.runAllTimers(); + expect(dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('setRefreshInterval', () => { + it('does nothing if refresh interval is unchanged', () => { + getState.mockReturnValue(refreshState); + + jest.useFakeTimers(); + const interval = 1; + middleware(setRefreshInterval(interval)); + jest.runAllTimers(); + + expect(setAppStateRefreshInterval).not.toBeCalled(); + }); + + it('sets the app refresh interval', () => { + getState.mockReturnValue(noRefreshState); + next.mockImplementation(() => { + getState.mockReturnValue(refreshState); + }); + + jest.useFakeTimers(); + const interval = 1; + middleware(setRefreshInterval(interval)); + + expect(setAppStateRefreshInterval).toBeCalledWith(createTimeInterval(interval)); + jest.runAllTimers(); + }); + + it('starts a refresh for the new interval', () => { + getState.mockReturnValue(refreshState); + jest.useFakeTimers(); + + const interval = 1000; + + middleware(inFlightComplete()); + + jest.runTimersToTime(refreshState.transient.refresh.interval - 1); + expect(dispatch).not.toBeCalled(); + + getState.mockReturnValue(noRefreshState); + next.mockImplementation(() => { + getState.mockReturnValue(refreshState); + }); + middleware(setRefreshInterval(interval)); + jest.runTimersToTime(1); + + expect(dispatch).not.toBeCalled(); + + jest.runTimersToTime(interval); + expect(dispatch).toBeCalled(); + }); + }); + + describe('inFlight in progress', () => { + it('requeues the refresh when inflight is active', () => { + jest.useFakeTimers(); + getState.mockReturnValue(inFlightState); + + middleware(inFlightComplete()); + jest.runTimersToTime(refreshState.transient.refresh.interval); + + expect(dispatch).not.toBeCalled(); + + getState.mockReturnValue(refreshState); + jest.runAllTimers(); + + expect(dispatch).toBeCalled(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.js b/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.ts similarity index 77% rename from x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.js rename to x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.ts index 886620c5404cd..700905213f54c 100644 --- a/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.js +++ b/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_autoplay.ts @@ -4,16 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { inFlightComplete } from '../actions/resolved_args'; +import { Middleware } from 'redux'; +import { State } from '../../../types'; import { getFullscreen } from '../selectors/app'; import { getInFlight } from '../selectors/resolved_args'; import { getWorkpad, getPages, getSelectedPageIndex, getAutoplay } from '../selectors/workpad'; +// @ts-ignore untyped local import { routerProvider } from '../../lib/router_provider'; import { setAutoplayInterval } from '../../lib/app_state'; import { createTimeInterval } from '../../lib/time_interval'; -export const workpadAutoplay = ({ getState }) => next => { - let playTimeout; +export const workpadAutoplay: Middleware<{}, State> = ({ getState }) => next => { + let playTimeout: number | undefined; let displayInterval = 0; const router = routerProvider(); @@ -42,18 +44,22 @@ export const workpadAutoplay = ({ getState }) => next => { } } + stopAutoUpdate(); startDelayedUpdate(); } function stopAutoUpdate() { clearTimeout(playTimeout); // cancel any pending update requests + playTimeout = undefined; } function startDelayedUpdate() { - stopAutoUpdate(); - playTimeout = setTimeout(() => { - updateWorkpad(); - }, displayInterval); + if (!playTimeout) { + stopAutoUpdate(); + playTimeout = window.setTimeout(() => { + updateWorkpad(); + }, displayInterval); + } } return action => { @@ -68,21 +74,14 @@ export const workpadAutoplay = ({ getState }) => next => { if (autoplay.enabled) { setAutoplayInterval(createTimeInterval(autoplay.interval)); } else { - setAutoplayInterval(0); + setAutoplayInterval(null); } - // when in-flight requests are finished, update the workpad after a given delay - if (action.type === inFlightComplete.toString() && shouldPlay) { - startDelayedUpdate(); - } // create new update request - - // This middleware creates or destroys an interval that will cause workpad elements to update - // clear any pending timeout - stopAutoUpdate(); - // if interval is larger than 0, start the delayed update if (shouldPlay) { startDelayedUpdate(); + } else { + stopAutoUpdate(); } }; }; diff --git a/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.js b/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.ts similarity index 65% rename from x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.js rename to x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.ts index 32822529f320c..f638c42ec2de0 100644 --- a/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.js +++ b/x-pack/legacy/plugins/canvas/public/state/middleware/workpad_refresh.ts @@ -4,18 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Middleware } from 'redux'; +import { State } from '../../../types'; +// @ts-ignore Untyped Local import { fetchAllRenderables } from '../actions/elements'; +// @ts-ignore Untyped Local import { setRefreshInterval } from '../actions/workpad'; import { inFlightComplete } from '../actions/resolved_args'; import { getInFlight } from '../selectors/resolved_args'; +import { getRefreshInterval } from '../selectors/workpad'; import { setRefreshInterval as setAppStateRefreshInterval } from '../../lib/app_state'; import { createTimeInterval } from '../../lib/time_interval'; -export const workpadRefresh = ({ dispatch, getState }) => next => { - let refreshTimeout; +export const workpadRefresh: Middleware<{}, State> = ({ dispatch, getState }) => next => { + let refreshTimeout: number | undefined; let refreshInterval = 0; function updateWorkpad() { + cancelDelayedUpdate(); + if (refreshInterval === 0) { return; } @@ -31,30 +38,43 @@ export const workpadRefresh = ({ dispatch, getState }) => next => { } } + function cancelDelayedUpdate() { + clearTimeout(refreshTimeout); + refreshTimeout = undefined; + } + function startDelayedUpdate() { - clearTimeout(refreshTimeout); // cancel any pending update requests - refreshTimeout = setTimeout(() => { - updateWorkpad(); - }, refreshInterval); + if (!refreshTimeout) { + clearTimeout(refreshTimeout); // cancel any pending update requests + refreshTimeout = window.setTimeout(() => { + updateWorkpad(); + }, refreshInterval); + } } return action => { + const previousRefreshInterval = getRefreshInterval(getState()); next(action); + refreshInterval = getRefreshInterval(getState()); + // when in-flight requests are finished, update the workpad after a given delay if (action.type === inFlightComplete.toString() && refreshInterval > 0) { startDelayedUpdate(); } // create new update request // This middleware creates or destroys an interval that will cause workpad elements to update - if (action.type === setRefreshInterval.toString()) { + if ( + action.type === setRefreshInterval.toString() && + previousRefreshInterval !== refreshInterval + ) { // update the refresh interval refreshInterval = action.payload; setAppStateRefreshInterval(createTimeInterval(refreshInterval)); // clear any pending timeout - clearTimeout(refreshTimeout); + cancelDelayedUpdate(); // if interval is larger than 0, start the delayed update if (refreshInterval > 0) { From 8edb53ddbc8676c456520100edff782cc260ef86 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 8 Jan 2020 13:46:01 -0600 Subject: [PATCH 21/63] [Metrics UI] Pass relevant shouldAllowEdit capabilities into SettingsPage (#49781) * [Metrics UI] Pass relevant shouldAllowEdit capabilities into SettingsPage * Split settings pages in two; add loading screen to settings page * Restore timestamp field to metrics screen Co-authored-by: Elastic Machine --- .../fields_configuration_panel.tsx | 386 +++++++++--------- .../indices_configuration_panel.tsx | 172 ++++---- .../source_configuration_settings.tsx | 30 +- .../public/pages/infrastructure/index.tsx | 4 +- .../public/pages/infrastructure/settings.tsx | 19 + .../plugins/infra/public/pages/logs/index.tsx | 4 +- .../settings/index.tsx => logs/settings.tsx} | 7 +- 7 files changed, 334 insertions(+), 288 deletions(-) create mode 100644 x-pack/legacy/plugins/infra/public/pages/infrastructure/settings.tsx rename x-pack/legacy/plugins/infra/public/pages/{shared/settings/index.tsx => logs/settings.tsx} (64%) diff --git a/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx b/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx index 5f3d1a63e72eb..e65753ef24e9e 100644 --- a/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx +++ b/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx @@ -26,6 +26,7 @@ interface FieldsConfigurationPanelProps { podFieldProps: InputFieldProps; tiebreakerFieldProps: InputFieldProps; timestampFieldProps: InputFieldProps; + displaySettings: 'metrics' | 'logs'; } export const FieldsConfigurationPanel = ({ @@ -36,6 +37,7 @@ export const FieldsConfigurationPanel = ({ podFieldProps, tiebreakerFieldProps, timestampFieldProps, + displaySettings, }: FieldsConfigurationPanelProps) => ( @@ -94,193 +96,201 @@ export const FieldsConfigurationPanel = ({ /> - - - - } - description={ - - } - > - _doc, - }} - /> - } - isInvalid={tiebreakerFieldProps.isInvalid} - label={ - - } - > - - - - - - - } - description={ - - } - > - container.id, - }} - /> - } - isInvalid={containerFieldProps.isInvalid} - label={ - - } - > - - - - - - - } - description={ - - } - > - host.name, - }} - /> - } - isInvalid={hostFieldProps.isInvalid} - label={ - - } - > - - - - - - - } - description={ - - } - > - kubernetes.pod.uid, - }} - /> - } - isInvalid={podFieldProps.isInvalid} - label={ - - } - > - - - + {displaySettings === 'logs' && ( + <> + + + + } + description={ + + } + > + _doc, + }} + /> + } + isInvalid={tiebreakerFieldProps.isInvalid} + label={ + + } + > + + + + + )} + {displaySettings === 'metrics' && ( + <> + + + + } + description={ + + } + > + container.id, + }} + /> + } + isInvalid={containerFieldProps.isInvalid} + label={ + + } + > + + + + + + + } + description={ + + } + > + host.name, + }} + /> + } + isInvalid={hostFieldProps.isInvalid} + label={ + + } + > + + + + + + + } + description={ + + } + > + kubernetes.pod.uid, + }} + /> + } + isInvalid={podFieldProps.isInvalid} + label={ + + } + > + + + + + )} ); diff --git a/x-pack/legacy/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx b/x-pack/legacy/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx index e779b35975ec3..eed6768c8846c 100644 --- a/x-pack/legacy/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx +++ b/x-pack/legacy/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx @@ -23,6 +23,7 @@ interface IndicesConfigurationPanelProps { readOnly: boolean; logAliasFieldProps: InputFieldProps; metricAliasFieldProps: InputFieldProps; + displaySettings: 'metrics' | 'logs'; } export const IndicesConfigurationPanel = ({ @@ -30,6 +31,7 @@ export const IndicesConfigurationPanel = ({ readOnly, logAliasFieldProps, metricAliasFieldProps, + displaySettings, }: IndicesConfigurationPanelProps) => ( @@ -41,101 +43,105 @@ export const IndicesConfigurationPanel = ({ - - - - } - description={ - - } - > - metricbeat-*, - }} - /> + {displaySettings === 'metrics' && ( + + + } - isInvalid={metricAliasFieldProps.isInvalid} - label={ + description={ } > - - - - - - - } - description={ - - } - > - filebeat-*, - }} + helpText={ + metricbeat-*, + }} + /> + } + isInvalid={metricAliasFieldProps.isInvalid} + label={ + + } + > + + + + )} + {displaySettings === 'logs' && ( + + + } - isInvalid={logAliasFieldProps.isInvalid} - label={ + description={ } > - - - + helpText={ + filebeat-*, + }} + /> + } + isInvalid={logAliasFieldProps.isInvalid} + label={ + + } + > + + + + )} ); diff --git a/x-pack/legacy/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx b/x-pack/legacy/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx index 31afedc8f31ee..68dbdf38e6af6 100644 --- a/x-pack/legacy/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx +++ b/x-pack/legacy/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx @@ -25,13 +25,16 @@ import { IndicesConfigurationPanel } from './indices_configuration_panel'; import { NameConfigurationPanel } from './name_configuration_panel'; import { LogColumnsConfigurationPanel } from './log_columns_configuration_panel'; import { useSourceConfigurationFormState } from './source_configuration_form_state'; +import { SourceLoadingPage } from '../source_loading_page'; interface SourceConfigurationSettingsProps { shouldAllowEdit: boolean; + displaySettings: 'metrics' | 'logs'; } export const SourceConfigurationSettings = ({ shouldAllowEdit, + displaySettings, }: SourceConfigurationSettingsProps) => { const { createSourceConfiguration, @@ -80,7 +83,10 @@ export const SourceConfigurationSettings = ({ source, ]); - if (!source || !source.configuration) { + if (!source) { + return ; + } + if (!source.configuration) { return null; } @@ -112,6 +118,7 @@ export const SourceConfigurationSettings = ({ logAliasFieldProps={indicesConfigurationProps.logAlias} metricAliasFieldProps={indicesConfigurationProps.metricAlias} readOnly={!isWriteable} + displaySettings={displaySettings} /> @@ -124,18 +131,21 @@ export const SourceConfigurationSettings = ({ readOnly={!isWriteable} tiebreakerFieldProps={indicesConfigurationProps.tiebreakerField} timestampFieldProps={indicesConfigurationProps.timestampField} + displaySettings={displaySettings} /> - - - + {displaySettings === 'logs' && ( + + + + )} {errors.length > 0 ? ( <> diff --git a/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx b/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx index dfe4fb05d669a..5eaa2850aebdb 100644 --- a/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx @@ -20,7 +20,7 @@ import { WithSource } from '../../containers/with_source'; import { Source } from '../../containers/source'; import { MetricsExplorerPage } from './metrics_explorer'; import { SnapshotPage } from './snapshot'; -import { SettingsPage } from '../shared/settings'; +import { MetricsSettingsPage } from './settings'; import { AppNavigation } from '../../components/navigation/app_navigation'; import { SourceLoadingPage } from '../../components/source_loading_page'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; @@ -106,7 +106,7 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { )} /> - + diff --git a/x-pack/legacy/plugins/infra/public/pages/infrastructure/settings.tsx b/x-pack/legacy/plugins/infra/public/pages/infrastructure/settings.tsx new file mode 100644 index 0000000000000..d75af7879d17a --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/infrastructure/settings.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { SourceConfigurationSettings } from '../../components/source_configuration/source_configuration_settings'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; + +export const MetricsSettingsPage = () => { + const uiCapabilities = useKibana().services.application?.capabilities; + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx index a8a75f99253c2..f38f066b5323f 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx @@ -17,7 +17,7 @@ import { SourceLoadingPage } from '../../components/source_loading_page'; import { SourceErrorPage } from '../../components/source_error_page'; import { Source, useSource } from '../../containers/source'; import { StreamPage } from './stream'; -import { SettingsPage } from '../shared/settings'; +import { LogsSettingsPage } from './settings'; import { AppNavigation } from '../../components/navigation/app_navigation'; import { useLogAnalysisCapabilities, @@ -107,7 +107,7 @@ export const LogsPage = ({ match }: RouteComponentProps) => { - + { +export const LogsSettingsPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; return ( ); }; From bbe700d797362c30af60f7269ab1cc81085a0a96 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 8 Jan 2020 14:48:00 -0500 Subject: [PATCH 22/63] Update schemas boolean, byteSize, and duration to coerce strings (#54177) * Update Duration to coerce number strings to numbers (in millis) * Coerce in a way that's consistent with kbn-config-schema * Update ByteSizeValue to coerce strings to numbers * Update Boolean to coerce strings to boolean values * Fix Jest test * Address PR review feedback * Whoops * Whoops 2 * Whoops 3 --- packages/kbn-config-schema/README.md | 7 +++-- .../__snapshots__/index.test.ts.snap | 10 ++++--- .../src/byte_size_value/index.test.ts | 10 +++---- .../src/byte_size_value/index.ts | 14 ++++++---- .../kbn-config-schema/src/duration/index.ts | 15 ++++++----- .../kbn-config-schema/src/internals/index.ts | 18 ++++++++++++- .../__snapshots__/boolean_type.test.ts.snap | 4 +++ .../__snapshots__/byte_size_type.test.ts.snap | 26 +++++++++++-------- .../__snapshots__/duration_type.test.ts.snap | 18 ++++++++----- .../src/types/boolean_type.test.ts | 15 +++++++++++ .../src/types/byte_size_type.test.ts | 24 +++++++++++++++-- .../src/types/duration_type.test.ts | 24 +++++++++++++++-- .../builtin_action_types/es_index.test.ts | 2 +- 13 files changed, 141 insertions(+), 46 deletions(-) diff --git a/packages/kbn-config-schema/README.md b/packages/kbn-config-schema/README.md index fd62f1b3c03b2..e6f3e60128983 100644 --- a/packages/kbn-config-schema/README.md +++ b/packages/kbn-config-schema/README.md @@ -156,6 +156,9 @@ __Usage:__ const valueSchema = schema.boolean({ defaultValue: false }); ``` +__Notes:__ +* The `schema.boolean()` also supports a string as input if it equals `'true'` or `'false'` (case-insensitive). + #### `schema.literal()` Validates input data as a [string](https://www.typescriptlang.org/docs/handbook/advanced-types.html#string-literal-types), [numeric](https://www.typescriptlang.org/docs/handbook/advanced-types.html#numeric-literal-types) or boolean literal. @@ -397,7 +400,7 @@ const valueSchema = schema.byteSize({ min: '3kb' }); ``` __Notes:__ -* The string value for `schema.byteSize()` and its options supports the following prefixes: `b`, `kb`, `mb`, `gb` and `tb`. +* The string value for `schema.byteSize()` and its options supports the following optional suffixes: `b`, `kb`, `mb`, `gb` and `tb`. The default suffix is `b`. * The number value is treated as a number of bytes and hence should be a positive integer, e.g. `100` is equal to `'100b'`. * Currently you cannot specify zero bytes with a string format and should use number `0` instead. @@ -417,7 +420,7 @@ const valueSchema = schema.duration({ defaultValue: '70ms' }); ``` __Notes:__ -* The string value for `schema.duration()` supports the following prefixes: `ms`, `s`, `m`, `h`, `d`, `w`, `M` and `Y`. +* The string value for `schema.duration()` supports the following optional suffixes: `ms`, `s`, `m`, `h`, `d`, `w`, `M` and `Y`. The default suffix is `ms`. * The number value is treated as a number of milliseconds and hence should be a positive integer, e.g. `100` is equal to `'100ms'`. #### `schema.conditional()` diff --git a/packages/kbn-config-schema/src/byte_size_value/__snapshots__/index.test.ts.snap b/packages/kbn-config-schema/src/byte_size_value/__snapshots__/index.test.ts.snap index 1db6930062a9a..97e9082401b3d 100644 --- a/packages/kbn-config-schema/src/byte_size_value/__snapshots__/index.test.ts.snap +++ b/packages/kbn-config-schema/src/byte_size_value/__snapshots__/index.test.ts.snap @@ -1,9 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`#constructor throws if number of bytes is negative 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-1024]"`; +exports[`#constructor throws if number of bytes is negative 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-1024]."`; -exports[`#constructor throws if number of bytes is not safe 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]"`; +exports[`#constructor throws if number of bytes is not safe 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]."`; -exports[`#constructor throws if number of bytes is not safe 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]"`; +exports[`#constructor throws if number of bytes is not safe 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]."`; -exports[`#constructor throws if number of bytes is not safe 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]"`; +exports[`#constructor throws if number of bytes is not safe 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]."`; + +exports[`parsing units throws an error when unsupported unit specified 1`] = `"Failed to parse [1tb] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."`; diff --git a/packages/kbn-config-schema/src/byte_size_value/index.test.ts b/packages/kbn-config-schema/src/byte_size_value/index.test.ts index 46ed96c83dd1f..198d95aa0ab4c 100644 --- a/packages/kbn-config-schema/src/byte_size_value/index.test.ts +++ b/packages/kbn-config-schema/src/byte_size_value/index.test.ts @@ -20,6 +20,10 @@ import { ByteSizeValue } from '.'; describe('parsing units', () => { + test('number string (bytes)', () => { + expect(ByteSizeValue.parse('123').getValueInBytes()).toBe(123); + }); + test('bytes', () => { expect(ByteSizeValue.parse('123b').getValueInBytes()).toBe(123); }); @@ -37,12 +41,8 @@ describe('parsing units', () => { expect(ByteSizeValue.parse('1gb').getValueInBytes()).toBe(1073741824); }); - test('throws an error when no unit specified', () => { - expect(() => ByteSizeValue.parse('123')).toThrowError('could not parse byte size value'); - }); - test('throws an error when unsupported unit specified', () => { - expect(() => ByteSizeValue.parse('1tb')).toThrowError('could not parse byte size value'); + expect(() => ByteSizeValue.parse('1tb')).toThrowErrorMatchingSnapshot(); }); }); diff --git a/packages/kbn-config-schema/src/byte_size_value/index.ts b/packages/kbn-config-schema/src/byte_size_value/index.ts index fb0105503a149..48862821bb78d 100644 --- a/packages/kbn-config-schema/src/byte_size_value/index.ts +++ b/packages/kbn-config-schema/src/byte_size_value/index.ts @@ -35,9 +35,14 @@ export class ByteSizeValue { public static parse(text: string): ByteSizeValue { const match = /([1-9][0-9]*)(b|kb|mb|gb)/.exec(text); if (!match) { - throw new Error( - `could not parse byte size value [${text}]. Value must be a safe positive integer.` - ); + const number = Number(text); + if (typeof number !== 'number' || isNaN(number)) { + throw new Error( + `Failed to parse [${text}] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] ` + + `(e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer.` + ); + } + return new ByteSizeValue(number); } const value = parseInt(match[1], 0); @@ -49,8 +54,7 @@ export class ByteSizeValue { constructor(private readonly valueInBytes: number) { if (!Number.isSafeInteger(valueInBytes) || valueInBytes < 0) { throw new Error( - `Value in bytes is expected to be a safe positive integer, ` + - `but provided [${valueInBytes}]` + `Value in bytes is expected to be a safe positive integer, but provided [${valueInBytes}].` ); } } diff --git a/packages/kbn-config-schema/src/duration/index.ts b/packages/kbn-config-schema/src/duration/index.ts index ff8f96614a193..b96b5a3687bbb 100644 --- a/packages/kbn-config-schema/src/duration/index.ts +++ b/packages/kbn-config-schema/src/duration/index.ts @@ -25,10 +25,14 @@ const timeFormatRegex = /^(0|[1-9][0-9]*)(ms|s|m|h|d|w|M|Y)$/; function stringToDuration(text: string) { const result = timeFormatRegex.exec(text); if (!result) { - throw new Error( - `Failed to parse [${text}] as time value. ` + - `Format must be [ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y')` - ); + const number = Number(text); + if (typeof number !== 'number' || isNaN(number)) { + throw new Error( + `Failed to parse [${text}] as time value. Value must be a duration in milliseconds, or follow the format ` + + `[ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y'), where the duration is a safe positive integer.` + ); + } + return numberToDuration(number); } const count = parseInt(result[1], 0); @@ -40,8 +44,7 @@ function stringToDuration(text: string) { function numberToDuration(numberMs: number) { if (!Number.isSafeInteger(numberMs) || numberMs < 0) { throw new Error( - `Failed to parse [${numberMs}] as time value. ` + - `Value should be a safe positive integer number.` + `Value in milliseconds is expected to be a safe positive integer, but provided [${numberMs}].` ); } diff --git a/packages/kbn-config-schema/src/internals/index.ts b/packages/kbn-config-schema/src/internals/index.ts index 4d5091eaa09b1..044c3050f9fa8 100644 --- a/packages/kbn-config-schema/src/internals/index.ts +++ b/packages/kbn-config-schema/src/internals/index.ts @@ -82,7 +82,23 @@ export const internals = Joi.extend([ base: Joi.boolean(), coerce(value: any, state: State, options: ValidationOptions) { // If value isn't defined, let Joi handle default value if it's defined. - if (value !== undefined && typeof value !== 'boolean') { + if (value === undefined) { + return value; + } + + // Allow strings 'true' and 'false' to be coerced to booleans (case-insensitive). + + // From Joi docs on `Joi.boolean`: + // > Generates a schema object that matches a boolean data type. Can also + // > be called via bool(). If the validation convert option is on + // > (enabled by default), a string (either "true" or "false") will be + // converted to a boolean if specified. + if (typeof value === 'string') { + const normalized = value.toLowerCase(); + value = normalized === 'true' ? true : normalized === 'false' ? false : value; + } + + if (typeof value !== 'boolean') { return this.createError('boolean.base', { value }, state, options); } diff --git a/packages/kbn-config-schema/src/types/__snapshots__/boolean_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/boolean_type.test.ts.snap index c3f33dc29bf50..0e5f6de2deea8 100644 --- a/packages/kbn-config-schema/src/types/__snapshots__/boolean_type.test.ts.snap +++ b/packages/kbn-config-schema/src/types/__snapshots__/boolean_type.test.ts.snap @@ -9,3 +9,7 @@ exports[`returns error when not boolean 1`] = `"expected value of type [boolean] exports[`returns error when not boolean 2`] = `"expected value of type [boolean] but got [Array]"`; exports[`returns error when not boolean 3`] = `"expected value of type [boolean] but got [string]"`; + +exports[`returns error when not boolean 4`] = `"expected value of type [boolean] but got [number]"`; + +exports[`returns error when not boolean 5`] = `"expected value of type [boolean] but got [string]"`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/byte_size_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/byte_size_type.test.ts.snap index f6f45a96ca161..ea2102b1776fb 100644 --- a/packages/kbn-config-schema/src/types/__snapshots__/byte_size_type.test.ts.snap +++ b/packages/kbn-config-schema/src/types/__snapshots__/byte_size_type.test.ts.snap @@ -18,6 +18,12 @@ ByteSizeValue { } `; +exports[`#defaultValue can be a string-formatted number 1`] = ` +ByteSizeValue { + "valueInBytes": 1024, +} +`; + exports[`#max returns error when larger 1`] = `"Value is [1mb] ([1048576b]) but it must be equal to or less than [1kb]"`; exports[`#max returns value when smaller 1`] = ` @@ -38,20 +44,18 @@ exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value o exports[`is required by default 1`] = `"expected value of type [ByteSize] but got [undefined]"`; -exports[`returns error when not string or positive safe integer 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-123]"`; +exports[`returns error when not valid string or positive safe integer 1`] = `"Value in bytes is expected to be a safe positive integer, but provided [-123]."`; -exports[`returns error when not string or positive safe integer 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]"`; +exports[`returns error when not valid string or positive safe integer 2`] = `"Value in bytes is expected to be a safe positive integer, but provided [NaN]."`; -exports[`returns error when not string or positive safe integer 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]"`; +exports[`returns error when not valid string or positive safe integer 3`] = `"Value in bytes is expected to be a safe positive integer, but provided [Infinity]."`; -exports[`returns error when not string or positive safe integer 4`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]"`; +exports[`returns error when not valid string or positive safe integer 4`] = `"Value in bytes is expected to be a safe positive integer, but provided [9007199254740992]."`; -exports[`returns error when not string or positive safe integer 5`] = `"expected value of type [ByteSize] but got [Array]"`; +exports[`returns error when not valid string or positive safe integer 5`] = `"expected value of type [ByteSize] but got [Array]"`; -exports[`returns error when not string or positive safe integer 6`] = `"expected value of type [ByteSize] but got [RegExp]"`; +exports[`returns error when not valid string or positive safe integer 6`] = `"expected value of type [ByteSize] but got [RegExp]"`; -exports[`returns value by default 1`] = ` -ByteSizeValue { - "valueInBytes": 123, -} -`; +exports[`returns error when not valid string or positive safe integer 7`] = `"Failed to parse [123foo] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."`; + +exports[`returns error when not valid string or positive safe integer 8`] = `"Failed to parse [123 456] as byte value. Value must be either number of bytes, or follow the format [b|kb|mb|gb] (e.g., '1024kb', '200mb', '1gb'), where the number is a safe positive integer."`; diff --git a/packages/kbn-config-schema/src/types/__snapshots__/duration_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/duration_type.test.ts.snap index a21c28e7cc614..c4e4ff652a2d7 100644 --- a/packages/kbn-config-schema/src/types/__snapshots__/duration_type.test.ts.snap +++ b/packages/kbn-config-schema/src/types/__snapshots__/duration_type.test.ts.snap @@ -6,20 +6,24 @@ exports[`#defaultValue can be a number 1`] = `"PT0.6S"`; exports[`#defaultValue can be a string 1`] = `"PT1H"`; +exports[`#defaultValue can be a string-formatted number 1`] = `"PT0.6S"`; + exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [moment.Duration] but got [undefined]"`; exports[`is required by default 1`] = `"expected value of type [moment.Duration] but got [undefined]"`; -exports[`returns error when not string or non-safe positive integer 1`] = `"Failed to parse [-123] as time value. Value should be a safe positive integer number."`; +exports[`returns error when not valid string or non-safe positive integer 1`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [-123]."`; + +exports[`returns error when not valid string or non-safe positive integer 2`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [NaN]."`; -exports[`returns error when not string or non-safe positive integer 2`] = `"Failed to parse [NaN] as time value. Value should be a safe positive integer number."`; +exports[`returns error when not valid string or non-safe positive integer 3`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [Infinity]."`; -exports[`returns error when not string or non-safe positive integer 3`] = `"Failed to parse [Infinity] as time value. Value should be a safe positive integer number."`; +exports[`returns error when not valid string or non-safe positive integer 4`] = `"Value in milliseconds is expected to be a safe positive integer, but provided [9007199254740992]."`; -exports[`returns error when not string or non-safe positive integer 4`] = `"Failed to parse [9007199254740992] as time value. Value should be a safe positive integer number."`; +exports[`returns error when not valid string or non-safe positive integer 5`] = `"expected value of type [moment.Duration] but got [Array]"`; -exports[`returns error when not string or non-safe positive integer 5`] = `"expected value of type [moment.Duration] but got [Array]"`; +exports[`returns error when not valid string or non-safe positive integer 6`] = `"expected value of type [moment.Duration] but got [RegExp]"`; -exports[`returns error when not string or non-safe positive integer 6`] = `"expected value of type [moment.Duration] but got [RegExp]"`; +exports[`returns error when not valid string or non-safe positive integer 7`] = `"Failed to parse [123foo] as time value. Value must be a duration in milliseconds, or follow the format [ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y'), where the duration is a safe positive integer."`; -exports[`returns value by default 1`] = `"PT2M3S"`; +exports[`returns error when not valid string or non-safe positive integer 8`] = `"Failed to parse [123 456] as time value. Value must be a duration in milliseconds, or follow the format [ms|s|m|h|d|w|M|Y] (e.g. '70ms', '5s', '3d', '1Y'), where the duration is a safe positive integer."`; diff --git a/packages/kbn-config-schema/src/types/boolean_type.test.ts b/packages/kbn-config-schema/src/types/boolean_type.test.ts index d6e274f05e3ff..e94999b505437 100644 --- a/packages/kbn-config-schema/src/types/boolean_type.test.ts +++ b/packages/kbn-config-schema/src/types/boolean_type.test.ts @@ -23,6 +23,17 @@ test('returns value by default', () => { expect(schema.boolean().validate(true)).toBe(true); }); +test('handles boolean strings', () => { + expect(schema.boolean().validate('true')).toBe(true); + expect(schema.boolean().validate('TRUE')).toBe(true); + expect(schema.boolean().validate('True')).toBe(true); + expect(schema.boolean().validate('TrUe')).toBe(true); + expect(schema.boolean().validate('false')).toBe(false); + expect(schema.boolean().validate('FALSE')).toBe(false); + expect(schema.boolean().validate('False')).toBe(false); + expect(schema.boolean().validate('FaLse')).toBe(false); +}); + test('is required by default', () => { expect(() => schema.boolean().validate(undefined)).toThrowErrorMatchingSnapshot(); }); @@ -49,4 +60,8 @@ test('returns error when not boolean', () => { expect(() => schema.boolean().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); expect(() => schema.boolean().validate('abc')).toThrowErrorMatchingSnapshot(); + + expect(() => schema.boolean().validate(0)).toThrowErrorMatchingSnapshot(); + + expect(() => schema.boolean().validate('no')).toThrowErrorMatchingSnapshot(); }); diff --git a/packages/kbn-config-schema/src/types/byte_size_type.test.ts b/packages/kbn-config-schema/src/types/byte_size_type.test.ts index 67eae1e7c382a..7c65ec2945b49 100644 --- a/packages/kbn-config-schema/src/types/byte_size_type.test.ts +++ b/packages/kbn-config-schema/src/types/byte_size_type.test.ts @@ -23,7 +23,15 @@ import { ByteSizeValue } from '../byte_size_value'; const { byteSize } = schema; test('returns value by default', () => { - expect(byteSize().validate('123b')).toMatchSnapshot(); + expect(byteSize().validate('123b')).toEqual(new ByteSizeValue(123)); +}); + +test('handles numeric strings', () => { + expect(byteSize().validate('123')).toEqual(new ByteSizeValue(123)); +}); + +test('handles numbers', () => { + expect(byteSize().validate(123)).toEqual(new ByteSizeValue(123)); }); test('is required by default', () => { @@ -51,6 +59,14 @@ describe('#defaultValue', () => { ).toMatchSnapshot(); }); + test('can be a string-formatted number', () => { + expect( + byteSize({ + defaultValue: '1024', + }).validate(undefined) + ).toMatchSnapshot(); + }); + test('can be a number', () => { expect( byteSize({ @@ -88,7 +104,7 @@ describe('#max', () => { }); }); -test('returns error when not string or positive safe integer', () => { +test('returns error when not valid string or positive safe integer', () => { expect(() => byteSize().validate(-123)).toThrowErrorMatchingSnapshot(); expect(() => byteSize().validate(NaN)).toThrowErrorMatchingSnapshot(); @@ -100,4 +116,8 @@ test('returns error when not string or positive safe integer', () => { expect(() => byteSize().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); expect(() => byteSize().validate(/abc/)).toThrowErrorMatchingSnapshot(); + + expect(() => byteSize().validate('123foo')).toThrowErrorMatchingSnapshot(); + + expect(() => byteSize().validate('123 456')).toThrowErrorMatchingSnapshot(); }); diff --git a/packages/kbn-config-schema/src/types/duration_type.test.ts b/packages/kbn-config-schema/src/types/duration_type.test.ts index 39655d43d7b75..09e92ce727f2a 100644 --- a/packages/kbn-config-schema/src/types/duration_type.test.ts +++ b/packages/kbn-config-schema/src/types/duration_type.test.ts @@ -23,7 +23,15 @@ import { schema } from '..'; const { duration, object, contextRef, siblingRef } = schema; test('returns value by default', () => { - expect(duration().validate('123s')).toMatchSnapshot(); + expect(duration().validate('123s')).toEqual(momentDuration(123000)); +}); + +test('handles numeric string', () => { + expect(duration().validate('123000')).toEqual(momentDuration(123000)); +}); + +test('handles number', () => { + expect(duration().validate(123000)).toEqual(momentDuration(123000)); }); test('is required by default', () => { @@ -51,6 +59,14 @@ describe('#defaultValue', () => { ).toMatchSnapshot(); }); + test('can be a string-formatted number', () => { + expect( + duration({ + defaultValue: '600', + }).validate(undefined) + ).toMatchSnapshot(); + }); + test('can be a number', () => { expect( duration({ @@ -124,7 +140,7 @@ Object { }); }); -test('returns error when not string or non-safe positive integer', () => { +test('returns error when not valid string or non-safe positive integer', () => { expect(() => duration().validate(-123)).toThrowErrorMatchingSnapshot(); expect(() => duration().validate(NaN)).toThrowErrorMatchingSnapshot(); @@ -136,4 +152,8 @@ test('returns error when not string or non-safe positive integer', () => { expect(() => duration().validate([1, 2, 3])).toThrowErrorMatchingSnapshot(); expect(() => duration().validate(/abc/)).toThrowErrorMatchingSnapshot(); + + expect(() => duration().validate('123foo')).toThrowErrorMatchingSnapshot(); + + expect(() => duration().validate('123 456')).toThrowErrorMatchingSnapshot(); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts index 35d81ba74fa72..1da8b06e1587a 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -142,7 +142,7 @@ describe('params validation', () => { ); expect(() => { - validateParams(actionType, { refresh: 'true' }); + validateParams(actionType, { refresh: 'foo' }); }).toThrowErrorMatchingInlineSnapshot( `"error validating action params: [refresh]: expected value of type [boolean] but got [string]"` ); From e93c6b8d1a187c50867c60fc3a422ff868fbcc62 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Wed, 8 Jan 2020 15:07:14 -0500 Subject: [PATCH 23/63] [ML] DF Analytics Results: adds link to docs (#54189) * add doc links to evaluate panel for analytics jobs * fix confusion matrix dataGrid label * internationalize link text --- .../evaluate_panel.tsx | 55 +++++++++++-------- .../regression_exploration/evaluate_panel.tsx | 32 ++++++++++- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index 7bb6949db1a99..68ed2c08d0df1 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -8,6 +8,7 @@ import React, { FC, useState, useEffect, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { + EuiButtonEmpty, EuiDataGrid, EuiFlexGroup, EuiFlexItem, @@ -18,6 +19,7 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; +import { metadata } from 'ui/metadata'; import { ErrorCallout } from '../error_callout'; import { getDependentVar, @@ -243,7 +245,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) - + @@ -260,6 +262,25 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) {getTaskStateBadge(jobStatus)} + + + + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.classificationDocsLink', + { + defaultMessage: 'Classification evaluation docs ', + } + )} + + {error !== null && ( @@ -327,28 +348,18 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) - - - - - - - - - - - - - + + + - + = ({ jobConfig, jobStatus, searchQuery }) return ( - + @@ -238,6 +247,25 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) {getTaskStateBadge(jobStatus)} + + + + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.regressionDocsLink', + { + defaultMessage: 'Regression evaluation docs ', + } + )} + + From 26ce6104a9091574d4ff85325aa7e4b8565faf8c Mon Sep 17 00:00:00 2001 From: Dmitry Lemeshko Date: Wed, 8 Jan 2020 21:08:48 +0100 Subject: [PATCH 24/63] Code coverage setup on CI (#49003) * running code coverage in CI * apply review feedback * add custom function to upload merged coverage reports * fix artifacts upload without coverage * add file extension to fix validation * Check code_coverage is set * run oss tests via grunt task * review fixes Co-authored-by: Elastic Machine --- .ci/Jenkinsfile_coverage | 112 ++++++++++++++++++++ scripts/functional_tests.js | 19 +++- src/dev/jest/config.js | 2 +- src/legacy/server/config/schema.js | 2 +- tasks/config/run.js | 1 + tasks/function_test_groups.js | 29 ++--- test/functional/services/remote/remote.ts | 4 +- test/scripts/jenkins_build_kibana.sh | 7 +- test/scripts/jenkins_ci_group.sh | 38 ++++--- test/scripts/jenkins_unit.sh | 14 ++- test/scripts/jenkins_xpack.sh | 75 +++++++------ test/scripts/jenkins_xpack_build_kibana.sh | 17 +-- test/scripts/jenkins_xpack_ci_group.sh | 101 +++++++++--------- vars/kibanaPipeline.groovy | 17 +++ x-pack/dev-tools/jest/create_jest_config.js | 2 +- x-pack/scripts/functional_tests.js | 59 ++++++----- 16 files changed, 347 insertions(+), 152 deletions(-) create mode 100644 .ci/Jenkinsfile_coverage diff --git a/.ci/Jenkinsfile_coverage b/.ci/Jenkinsfile_coverage new file mode 100644 index 0000000000000..d9ec1861c9979 --- /dev/null +++ b/.ci/Jenkinsfile_coverage @@ -0,0 +1,112 @@ +#!/bin/groovy + +library 'kibana-pipeline-library' +kibanaLibrary.load() // load from the Jenkins instance + +stage("Kibana Pipeline") { // This stage is just here to help the BlueOcean UI a little bit + timeout(time: 180, unit: 'MINUTES') { + timestamps { + ansiColor('xterm') { + catchError { + withEnv([ + 'CODE_COVERAGE=1', // Needed for multiple ci scripts, such as remote.ts, test/scripts/*.sh, schema.js, etc. + ]) { + parallel([ + 'kibana-intake-agent': { + withEnv([ + 'NODE_ENV=test' // Needed for jest tests only + ]) { + kibanaPipeline.legacyJobRunner('kibana-intake')() + } + }, + 'x-pack-intake-agent': { + withEnv([ + 'NODE_ENV=test' // Needed for jest tests only + ]) { + kibanaPipeline.legacyJobRunner('x-pack-intake')() + } + }, + 'kibana-oss-agent': kibanaPipeline.withWorkers('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ + 'oss-ciGroup1': kibanaPipeline.getOssCiGroupWorker(1), + 'oss-ciGroup2': kibanaPipeline.getOssCiGroupWorker(2), + 'oss-ciGroup3': kibanaPipeline.getOssCiGroupWorker(3), + 'oss-ciGroup4': kibanaPipeline.getOssCiGroupWorker(4), + 'oss-ciGroup5': kibanaPipeline.getOssCiGroupWorker(5), + 'oss-ciGroup6': kibanaPipeline.getOssCiGroupWorker(6), + 'oss-ciGroup7': kibanaPipeline.getOssCiGroupWorker(7), + 'oss-ciGroup8': kibanaPipeline.getOssCiGroupWorker(8), + 'oss-ciGroup9': kibanaPipeline.getOssCiGroupWorker(9), + 'oss-ciGroup10': kibanaPipeline.getOssCiGroupWorker(10), + 'oss-ciGroup11': kibanaPipeline.getOssCiGroupWorker(11), + 'oss-ciGroup12': kibanaPipeline.getOssCiGroupWorker(12), + ]), + 'kibana-xpack-agent-1': kibanaPipeline.withWorkers('kibana-xpack-tests-1', { kibanaPipeline.buildXpack() }, [ + 'xpack-ciGroup1': kibanaPipeline.getXpackCiGroupWorker(1), + 'xpack-ciGroup2': kibanaPipeline.getXpackCiGroupWorker(2), + ]), + 'kibana-xpack-agent-2': kibanaPipeline.withWorkers('kibana-xpack-tests-2', { kibanaPipeline.buildXpack() }, [ + 'xpack-ciGroup3': kibanaPipeline.getXpackCiGroupWorker(3), + 'xpack-ciGroup4': kibanaPipeline.getXpackCiGroupWorker(4), + ]), + + 'kibana-xpack-agent-3': kibanaPipeline.withWorkers('kibana-xpack-tests-3', { kibanaPipeline.buildXpack() }, [ + 'xpack-ciGroup5': kibanaPipeline.getXpackCiGroupWorker(5), + 'xpack-ciGroup6': kibanaPipeline.getXpackCiGroupWorker(6), + 'xpack-ciGroup7': kibanaPipeline.getXpackCiGroupWorker(7), + 'xpack-ciGroup8': kibanaPipeline.getXpackCiGroupWorker(8), + 'xpack-ciGroup9': kibanaPipeline.getXpackCiGroupWorker(9), + 'xpack-ciGroup10': kibanaPipeline.getXpackCiGroupWorker(10), + ]), + ]) + kibanaPipeline.jobRunner('tests-l', false) { + kibanaPipeline.downloadCoverageArtifacts() + kibanaPipeline.bash( + ''' + # bootstrap from x-pack folder + source src/dev/ci_setup/setup_env.sh + cd x-pack + yarn kbn bootstrap --prefer-offline + cd .. + # extract archives + mkdir -p /tmp/extracted_coverage + echo extracting intakes + tar -xzf /tmp/downloaded_coverage/coverage/kibana-intake/kibana-coverage.tar.gz -C /tmp/extracted_coverage + tar -xzf /tmp/downloaded_coverage/coverage/x-pack-intake/kibana-coverage.tar.gz -C /tmp/extracted_coverage + echo extracting kibana-oss-tests + tar -xzf /tmp/downloaded_coverage/coverage/kibana-oss-tests/kibana-coverage.tar.gz -C /tmp/extracted_coverage + echo extracting kibana-xpack-tests + for i in {1..3}; do + tar -xzf /tmp/downloaded_coverage/coverage/kibana-xpack-tests-${i}/kibana-coverage.tar.gz -C /tmp/extracted_coverage + done + # replace path in json files to have valid html report + pwd=$(pwd) + du -sh /tmp/extracted_coverage/target/kibana-coverage/ + echo replacing path in json files + for i in {1..9}; do + sed -i "s|/dev/shm/workspace/kibana|$pwd|g" /tmp/extracted_coverage/target/kibana-coverage/functional/${i}*.json & + done + wait + # merge oss & x-pack reports + echo merging coverage reports + yarn nyc report --temp-dir /tmp/extracted_coverage/target/kibana-coverage/jest --report-dir target/kibana-coverage/jest-combined --reporter=html --reporter=json-summary + yarn nyc report --temp-dir /tmp/extracted_coverage/target/kibana-coverage/functional --report-dir target/kibana-coverage/functional-combined --reporter=html --reporter=json-summary + echo copy mocha reports + mkdir -p target/kibana-coverage/mocha-combined + cp -r /tmp/extracted_coverage/target/kibana-coverage/mocha target/kibana-coverage/mocha-combined + ''', + "run `yarn kbn bootstrap && merge coverage`" + ) + sh 'tar -czf kibana-jest-coverage.tar.gz target/kibana-coverage/jest-combined/*' + kibanaPipeline.uploadCoverageArtifacts("coverage/jest-combined", 'kibana-jest-coverage.tar.gz') + sh 'tar -czf kibana-functional-coverage.tar.gz target/kibana-coverage/functional-combined/*' + kibanaPipeline.uploadCoverageArtifacts("coverage/functional-combined", 'kibana-functional-coverage.tar.gz') + sh 'tar -czf kibana-mocha-coverage.tar.gz target/kibana-coverage/mocha-combined/*' + kibanaPipeline.uploadCoverageArtifacts("coverage/mocha-combined", 'kibana-mocha-coverage.tar.gz') + } + } + } + kibanaPipeline.sendMail() + } + } + } +} diff --git a/scripts/functional_tests.js b/scripts/functional_tests.js index 4472891e580fb..fc88f2657018f 100644 --- a/scripts/functional_tests.js +++ b/scripts/functional_tests.js @@ -17,12 +17,23 @@ * under the License. */ -require('../src/setup_node_env'); -require('@kbn/test').runTestsCli([ +// eslint-disable-next-line no-restricted-syntax +const alwaysImportedTests = [ require.resolve('../test/functional/config.js'), - require.resolve('../test/api_integration/config.js'), require.resolve('../test/plugin_functional/config.js'), - require.resolve('../test/interpreter_functional/config.ts'), require.resolve('../test/ui_capabilities/newsfeed_err/config.ts'), +]; +// eslint-disable-next-line no-restricted-syntax +const onlyNotInCoverageTests = [ + require.resolve('../test/api_integration/config.js'), + require.resolve('../test/interpreter_functional/config.ts'), require.resolve('../test/examples/config.js'), +]; + +require('../src/setup_node_env'); +require('@kbn/test').runTestsCli([ + // eslint-disable-next-line no-restricted-syntax + ...alwaysImportedTests, + // eslint-disable-next-line no-restricted-syntax + ...(!!process.env.CODE_COVERAGE ? [] : onlyNotInCoverageTests), ]); diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index 9321336f0f55e..1c95e75396bcc 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -69,7 +69,7 @@ export default { ], setupFilesAfterEnv: ['/src/dev/jest/setup/mocks.js'], coverageDirectory: '/target/kibana-coverage/jest', - coverageReporters: ['html', 'text'], + coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html', 'text'], moduleFileExtensions: ['js', 'json', 'ts', 'tsx'], modulePathIgnorePatterns: ['__fixtures__/', 'target/'], testMatch: ['**/*.test.{js,ts,tsx}'], diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index a18cb7de5a61b..a53e8e0498c42 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -181,7 +181,7 @@ export default () => .default('localhost'), watchPrebuild: Joi.boolean().default(false), watchProxyTimeout: Joi.number().default(10 * 60000), - useBundleCache: Joi.boolean().default(Joi.ref('$prod')), + useBundleCache: Joi.boolean().default(!!process.env.CODE_COVERAGE ? true : Joi.ref('$prod')), sourceMaps: Joi.when('$prod', { is: true, then: Joi.boolean().valid(false), diff --git a/tasks/config/run.js b/tasks/config/run.js index a29061c9a7240..857895d75595c 100644 --- a/tasks/config/run.js +++ b/tasks/config/run.js @@ -152,6 +152,7 @@ module.exports = function(grunt) { args: [ 'nyc', '--reporter=html', + '--reporter=json-summary', '--report-dir=./target/kibana-coverage/mocha', NODE, 'scripts/mocha', diff --git a/tasks/function_test_groups.js b/tasks/function_test_groups.js index 7854e2cd49837..7b7293dc9a037 100644 --- a/tasks/function_test_groups.js +++ b/tasks/function_test_groups.js @@ -29,6 +29,21 @@ const TEST_TAGS = safeLoad(JOBS_YAML) .JOB.filter(id => id.startsWith('kibana-ciGroup')) .map(id => id.replace(/^kibana-/, '')); +const getDefaultArgs = tag => { + return [ + 'scripts/functional_tests', + '--include-tag', + tag, + '--config', + 'test/functional/config.js', + '--config', + 'test/ui_capabilities/newsfeed_err/config.ts', + // '--config', 'test/functional/config.firefox.js', + '--bail', + '--debug', + ]; +}; + export function getFunctionalTestGroupRunConfigs({ kibanaInstallDir } = {}) { return { // include a run task for each test group @@ -38,18 +53,8 @@ export function getFunctionalTestGroupRunConfigs({ kibanaInstallDir } = {}) { [`functionalTests_${tag}`]: { cmd: process.execPath, args: [ - 'scripts/functional_tests', - '--include-tag', - tag, - '--config', - 'test/functional/config.js', - '--config', - 'test/ui_capabilities/newsfeed_err/config.ts', - // '--config', 'test/functional/config.firefox.js', - '--bail', - '--debug', - '--kibana-install-dir', - kibanaInstallDir, + ...getDefaultArgs(tag), + ...(!!process.env.CODE_COVERAGE ? [] : ['--kibana-install-dir', kibanaInstallDir]), ], }, }), diff --git a/test/functional/services/remote/remote.ts b/test/functional/services/remote/remote.ts index 69c2793621095..afe8499a1c2ea 100644 --- a/test/functional/services/remote/remote.ts +++ b/test/functional/services/remote/remote.ts @@ -100,7 +100,9 @@ export async function RemoteProvider({ getService }: FtrProviderContext) { .subscribe({ next({ message, level }) { const msg = message.replace(/\\n/g, '\n'); - log[level === 'SEVERE' ? 'error' : 'debug'](`browser[${level}] ${msg}`); + log[level === 'SEVERE' || level === 'error' ? 'error' : 'debug']( + `browser[${level}] ${msg}` + ); }, }); diff --git a/test/scripts/jenkins_build_kibana.sh b/test/scripts/jenkins_build_kibana.sh index f79fe98e07bef..2605655ed7e7a 100755 --- a/test/scripts/jenkins_build_kibana.sh +++ b/test/scripts/jenkins_build_kibana.sh @@ -8,5 +8,8 @@ node scripts/es snapshot --license=oss --download-only; echo " -> Ensuring all functional tests are in a ciGroup" yarn run grunt functionalTests:ensureAllTestsInCiGroup; -echo " -> building and extracting OSS Kibana distributable for use in functional tests" -node scripts/build --debug --oss +# Do not build kibana for code coverage run +if [[ -z "$CODE_COVERAGE" ]] ; then + echo " -> building and extracting OSS Kibana distributable for use in functional tests" + node scripts/build --debug --oss +fi diff --git a/test/scripts/jenkins_ci_group.sh b/test/scripts/jenkins_ci_group.sh index 1cb566c908dbf..fccdb29ff512b 100755 --- a/test/scripts/jenkins_ci_group.sh +++ b/test/scripts/jenkins_ci_group.sh @@ -2,22 +2,30 @@ source test/scripts/jenkins_test_setup.sh -if [[ -z "$IS_PIPELINE_JOB" ]] ; then - yarn run grunt functionalTests:ensureAllTestsInCiGroup; - node scripts/build --debug --oss; -else - installDir="$(realpath $PARENT_DIR/kibana/build/oss/kibana-*-SNAPSHOT-linux-x86_64)" - destDir=${installDir}-${CI_WORKER_NUMBER} - cp -R "$installDir" "$destDir" +if [[ -z "$CODE_COVERAGE" ]] ; then + if [[ -z "$IS_PIPELINE_JOB" ]] ; then + yarn run grunt functionalTests:ensureAllTestsInCiGroup; + node scripts/build --debug --oss; + else + installDir="$(realpath $PARENT_DIR/kibana/build/oss/kibana-*-SNAPSHOT-linux-x86_64)" + destDir=${installDir}-${CI_WORKER_NUMBER} + cp -R "$installDir" "$destDir" - export KIBANA_INSTALL_DIR="$destDir" -fi + export KIBANA_INSTALL_DIR="$destDir" + fi + + checks-reporter-with-killswitch "Functional tests / Group ${CI_GROUP}" yarn run grunt "run:functionalTests_ciGroup${CI_GROUP}"; + + if [ "$CI_GROUP" == "1" ]; then + source test/scripts/jenkins_build_kbn_tp_sample_panel_action.sh + yarn run grunt run:pluginFunctionalTestsRelease --from=source; + yarn run grunt run:exampleFunctionalTestsRelease --from=source; + yarn run grunt run:interpreterFunctionalTestsRelease; + fi +else + echo " -> Running Functional tests with code coverage" -checks-reporter-with-killswitch "Functional tests / Group ${CI_GROUP}" yarn run grunt "run:functionalTests_ciGroup${CI_GROUP}"; + export NODE_OPTIONS=--max_old_space_size=8192 -if [ "$CI_GROUP" == "1" ]; then - source test/scripts/jenkins_build_kbn_tp_sample_panel_action.sh - yarn run grunt run:pluginFunctionalTestsRelease --from=source; - yarn run grunt run:exampleFunctionalTestsRelease --from=source; - yarn run grunt run:interpreterFunctionalTestsRelease; + yarn run grunt "run:functionalTests_ciGroup${CI_GROUP}"; fi diff --git a/test/scripts/jenkins_unit.sh b/test/scripts/jenkins_unit.sh index 75610884b542f..a8b5e8e4fdf97 100755 --- a/test/scripts/jenkins_unit.sh +++ b/test/scripts/jenkins_unit.sh @@ -4,4 +4,16 @@ set -e export TEST_BROWSER_HEADLESS=1 -"$(FORCE_COLOR=0 yarn bin)/grunt" jenkins:unit --dev; +if [[ -z "$CODE_COVERAGE" ]] ; then + "$(FORCE_COLOR=0 yarn bin)/grunt" jenkins:unit --dev; +else + echo "NODE_ENV=$NODE_ENV" + echo " -> Running jest tests with coverage" + node scripts/jest --ci --verbose --coverage + echo "" + echo "" + echo " -> Running mocha tests with coverage" + yarn run grunt "test:mochaCoverage"; + echo "" + echo "" +fi diff --git a/test/scripts/jenkins_xpack.sh b/test/scripts/jenkins_xpack.sh index 27f73c0b6e20d..e0055085d9b37 100755 --- a/test/scripts/jenkins_xpack.sh +++ b/test/scripts/jenkins_xpack.sh @@ -4,33 +4,48 @@ set -e export TEST_BROWSER_HEADLESS=1 -echo " -> Running mocha tests" -cd "$XPACK_DIR" -checks-reporter-with-killswitch "X-Pack Karma Tests" yarn test:browser -echo "" -echo "" - -echo " -> Running jest tests" -cd "$XPACK_DIR" -checks-reporter-with-killswitch "X-Pack Jest" node scripts/jest --ci --verbose -echo "" -echo "" - -echo " -> Running SIEM cyclic dependency test" -cd "$XPACK_DIR" -checks-reporter-with-killswitch "X-Pack SIEM cyclic dependency test" node legacy/plugins/siem/scripts/check_circular_deps -echo "" -echo "" - -# FAILING: https://github.com/elastic/kibana/issues/44250 -# echo " -> Running jest contracts tests" -# cd "$XPACK_DIR" -# SLAPSHOT_ONLINE=true CONTRACT_ONLINE=true node scripts/jest_contract.js --ci --verbose -# echo "" -# echo "" - -# echo " -> Running jest integration tests" -# cd "$XPACK_DIR" -# node scripts/jest_integration --ci --verbose -# echo "" -# echo "" +if [[ -z "$CODE_COVERAGE" ]] ; then + echo " -> Running mocha tests" + cd "$XPACK_DIR" + checks-reporter-with-killswitch "X-Pack Karma Tests" yarn test:browser + echo "" + echo "" + + echo " -> Running jest tests" + cd "$XPACK_DIR" + checks-reporter-with-killswitch "X-Pack Jest" node scripts/jest --ci --verbose + echo "" + echo "" + + echo " -> Running SIEM cyclic dependency test" + cd "$XPACK_DIR" + checks-reporter-with-killswitch "X-Pack SIEM cyclic dependency test" node legacy/plugins/siem/scripts/check_circular_deps + echo "" + echo "" + + # FAILING: https://github.com/elastic/kibana/issues/44250 + # echo " -> Running jest contracts tests" + # cd "$XPACK_DIR" + # SLAPSHOT_ONLINE=true CONTRACT_ONLINE=true node scripts/jest_contract.js --ci --verbose + # echo "" + # echo "" + + # echo " -> Running jest integration tests" + # cd "$XPACK_DIR" + # node scripts/jest_integration --ci --verbose + # echo "" + # echo "" +else + echo " -> Running jest tests with coverage" + cd "$XPACK_DIR" + # build runtime for canvas + echo "NODE_ENV=$NODE_ENV" + node ./legacy/plugins/canvas/scripts/shareable_runtime + node scripts/jest --ci --verbose --coverage + # rename file in order to be unique one + test -f ../target/kibana-coverage/jest/coverage-final.json \ + && mv ../target/kibana-coverage/jest/coverage-final.json \ + ../target/kibana-coverage/jest/xpack-coverage-final.json + echo "" + echo "" +fi \ No newline at end of file diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index 9f2bafc863f41..20b12b302cb39 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -20,10 +20,13 @@ node scripts/functional_tests --assert-none-excluded \ --include-tag ciGroup9 \ --include-tag ciGroup10 -echo " -> building and extracting default Kibana distributable for use in functional tests" -cd "$KIBANA_DIR" -node scripts/build --debug --no-oss -linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" -installDir="$PARENT_DIR/install/kibana" -mkdir -p "$installDir" -tar -xzf "$linuxBuild" -C "$installDir" --strip=1 +# Do not build kibana for code coverage run +if [[ -z "$CODE_COVERAGE" ]] ; then + echo " -> building and extracting default Kibana distributable for use in functional tests" + cd "$KIBANA_DIR" + node scripts/build --debug --no-oss + linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" + installDir="$PARENT_DIR/install/kibana" + mkdir -p "$installDir" + tar -xzf "$linuxBuild" -C "$installDir" --strip=1 +fi diff --git a/test/scripts/jenkins_xpack_ci_group.sh b/test/scripts/jenkins_xpack_ci_group.sh index fba05f8f252d7..58c407a848ae3 100755 --- a/test/scripts/jenkins_xpack_ci_group.sh +++ b/test/scripts/jenkins_xpack_ci_group.sh @@ -2,59 +2,60 @@ source test/scripts/jenkins_test_setup.sh -if [[ -z "$IS_PIPELINE_JOB" ]] ; then - echo " -> Ensuring all functional tests are in a ciGroup" +if [[ -z "$CODE_COVERAGE" ]] ; then + if [[ -z "$IS_PIPELINE_JOB" ]] ; then + echo " -> Ensuring all functional tests are in a ciGroup" + cd "$XPACK_DIR" + node scripts/functional_tests --assert-none-excluded \ + --include-tag ciGroup1 \ + --include-tag ciGroup2 \ + --include-tag ciGroup3 \ + --include-tag ciGroup4 \ + --include-tag ciGroup5 \ + --include-tag ciGroup6 \ + --include-tag ciGroup7 \ + --include-tag ciGroup8 \ + --include-tag ciGroup9 \ + --include-tag ciGroup10 + fi + + cd "$KIBANA_DIR" + + if [[ -z "$IS_PIPELINE_JOB" ]] ; then + echo " -> building and extracting default Kibana distributable for use in functional tests" + node scripts/build --debug --no-oss + + linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" + installDir="$PARENT_DIR/install/kibana" + + mkdir -p "$installDir" + tar -xzf "$linuxBuild" -C "$installDir" --strip=1 + + export KIBANA_INSTALL_DIR="$installDir" + else + installDir="$PARENT_DIR/install/kibana" + destDir="${installDir}-${CI_WORKER_NUMBER}" + cp -R "$installDir" "$destDir" + + export KIBANA_INSTALL_DIR="$destDir" + fi + + echo " -> Running functional and api tests" cd "$XPACK_DIR" - node scripts/functional_tests --assert-none-excluded \ - --include-tag ciGroup1 \ - --include-tag ciGroup2 \ - --include-tag ciGroup3 \ - --include-tag ciGroup4 \ - --include-tag ciGroup5 \ - --include-tag ciGroup6 \ - --include-tag ciGroup7 \ - --include-tag ciGroup8 \ - --include-tag ciGroup9 \ - --include-tag ciGroup10 -fi - -cd "$KIBANA_DIR" - -if [[ -z "$IS_PIPELINE_JOB" ]] ; then - echo " -> building and extracting default Kibana distributable for use in functional tests" - node scripts/build --debug --no-oss - - linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" - installDir="$PARENT_DIR/install/kibana" - mkdir -p "$installDir" - tar -xzf "$linuxBuild" -C "$installDir" --strip=1 + checks-reporter-with-killswitch "X-Pack Chrome Functional tests / Group ${CI_GROUP}" \ + node scripts/functional_tests \ + --debug --bail \ + --kibana-install-dir "$KIBANA_INSTALL_DIR" \ + --include-tag "ciGroup$CI_GROUP" - export KIBANA_INSTALL_DIR="$installDir" + echo "" + echo "" else - installDir="$PARENT_DIR/install/kibana" - destDir="${installDir}-${CI_WORKER_NUMBER}" - cp -R "$installDir" "$destDir" + echo " -> Running X-Pack functional tests with code coverage" + cd "$XPACK_DIR" - export KIBANA_INSTALL_DIR="$destDir" -fi + export NODE_OPTIONS=--max_old_space_size=8192 -echo " -> Running functional and api tests" -cd "$XPACK_DIR" - -checks-reporter-with-killswitch "X-Pack Chrome Functional tests / Group ${CI_GROUP}" \ - node scripts/functional_tests \ - --debug --bail \ - --kibana-install-dir "$KIBANA_INSTALL_DIR" \ - --include-tag "ciGroup$CI_GROUP" - -echo "" -echo "" - -# checks-reporter-with-killswitch "X-Pack Firefox Functional tests / Group ${CI_GROUP}" \ -# node scripts/functional_tests --debug --bail \ -# --kibana-install-dir "$installDir" \ -# --include-tag "ciGroup$CI_GROUP" \ -# --config "test/functional/config.firefox.js" -# echo "" -# echo "" + node scripts/functional_tests --debug --include-tag "ciGroup$CI_GROUP" +fi diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index c778dd799f6e5..5c6be70514c61 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -176,6 +176,18 @@ def uploadGcsArtifact(uploadPrefix, pattern) { ) } +def downloadCoverageArtifacts() { + def storageLocation = "gs://kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/coverage/" + def targetLocation = "/tmp/downloaded_coverage" + + sh "mkdir -p '${targetLocation}' && gsutil -m cp -r '${storageLocation}' '${targetLocation}'" +} + +def uploadCoverageArtifacts(prefix, pattern) { + def uploadPrefix = "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/coverage/${prefix}" + uploadGcsArtifact(uploadPrefix, pattern) +} + def withGcsArtifactUpload(workerName, closure) { def uploadPrefix = "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/${workerName}" def ARTIFACT_PATTERNS = [ @@ -201,6 +213,11 @@ def withGcsArtifactUpload(workerName, closure) { } } }) + + if (env.CODE_COVERAGE) { + sh 'tar -czf kibana-coverage.tar.gz target/kibana-coverage/**/*' + uploadGcsArtifact("kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/coverage/${workerName}", 'kibana-coverage.tar.gz') + } } def publishJunit() { diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js index cd4414b5fdebe..02904cc48e030 100644 --- a/x-pack/dev-tools/jest/create_jest_config.js +++ b/x-pack/dev-tools/jest/create_jest_config.js @@ -30,7 +30,7 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) { '^test_utils/find_test_subject': `${xPackKibanaDirectory}/test_utils/find_test_subject.ts`, }, coverageDirectory: '/../target/kibana-coverage/jest', - coverageReporters: ['html'], + coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html'], setupFiles: [ `${kibanaDirectory}/src/dev/jest/setup/babel_polyfill.js`, `/dev-tools/jest/setup/polyfills.js`, diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 2b92e70fb30af..86db39823ba91 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -4,38 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ -require('@kbn/plugin-helpers').babelRegister(); -require('@kbn/test').runTestsCli([ +const alwaysImportedTests = [require.resolve('../test/functional/config.js')]; +const onlyNotInCoverageTests = [ require.resolve('../test/reporting/configs/chromium_api.js'), require.resolve('../test/reporting/configs/chromium_functional.js'), - require.resolve('../test/reporting/configs/generate_api'), - require.resolve('../test/functional/config.js'), + require.resolve('../test/reporting/configs/generate_api.js'), require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), require.resolve('../test/alerting_api_integration/spaces_only/config.ts'), require.resolve('../test/alerting_api_integration/security_and_spaces/config.ts'), require.resolve('../test/plugin_api_integration/config.js'), - require.resolve('../test/plugin_functional/config'), - require.resolve('../test/kerberos_api_integration/config'), - require.resolve('../test/kerberos_api_integration/anonymous_access.config'), - require.resolve('../test/saml_api_integration/config'), - require.resolve('../test/token_api_integration/config'), - require.resolve('../test/oidc_api_integration/config'), - require.resolve('../test/oidc_api_integration/implicit_flow.config'), - require.resolve('../test/pki_api_integration/config'), - require.resolve('../test/spaces_api_integration/spaces_only/config'), - require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial'), - require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic'), - require.resolve('../test/saved_object_api_integration/security_and_spaces/config_trial'), - require.resolve('../test/saved_object_api_integration/security_and_spaces/config_basic'), - require.resolve('../test/saved_object_api_integration/security_only/config_trial'), - require.resolve('../test/saved_object_api_integration/security_only/config_basic'), - require.resolve('../test/saved_object_api_integration/spaces_only/config'), - require.resolve('../test/ui_capabilities/security_and_spaces/config'), - require.resolve('../test/ui_capabilities/security_only/config'), - require.resolve('../test/ui_capabilities/spaces_only/config'), - require.resolve('../test/upgrade_assistant_integration/config'), - require.resolve('../test/licensing_plugin/config'), - require.resolve('../test/licensing_plugin/config.public'), - require.resolve('../test/licensing_plugin/config.legacy'), + require.resolve('../test/plugin_functional/config.ts'), + require.resolve('../test/kerberos_api_integration/config.ts'), + require.resolve('../test/kerberos_api_integration/anonymous_access.config.ts'), + require.resolve('../test/saml_api_integration/config.ts'), + require.resolve('../test/token_api_integration/config.js'), + require.resolve('../test/oidc_api_integration/config.ts'), + require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'), + require.resolve('../test/pki_api_integration/config.ts'), + require.resolve('../test/spaces_api_integration/spaces_only/config.ts'), + require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial.ts'), + require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic.ts'), + require.resolve('../test/saved_object_api_integration/security_and_spaces/config_trial.ts'), + require.resolve('../test/saved_object_api_integration/security_and_spaces/config_basic.ts'), + require.resolve('../test/saved_object_api_integration/security_only/config_trial.ts'), + require.resolve('../test/saved_object_api_integration/security_only/config_basic.ts'), + require.resolve('../test/saved_object_api_integration/spaces_only/config.ts'), + require.resolve('../test/ui_capabilities/security_and_spaces/config.ts'), + require.resolve('../test/ui_capabilities/security_only/config.ts'), + require.resolve('../test/ui_capabilities/spaces_only/config.ts'), + require.resolve('../test/upgrade_assistant_integration/config.js'), + require.resolve('../test/licensing_plugin/config.ts'), + require.resolve('../test/licensing_plugin/config.public.ts'), + require.resolve('../test/licensing_plugin/config.legacy.ts'), +]; + +require('@kbn/plugin-helpers').babelRegister(); +require('@kbn/test').runTestsCli([ + ...alwaysImportedTests, + ...(!!process.env.CODE_COVERAGE ? [] : onlyNotInCoverageTests), ]); From 303e4842ea14f9316bfa28d3029e2abcc92acb6d Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 8 Jan 2020 14:28:29 -0700 Subject: [PATCH 25/63] [SIEM] [Case] Case workflow api schema (#51535) --- .../lib/case/saved_object_mappings_temp.ts | 91 +++++++++ x-pack/plugins/case/README.md | 9 + x-pack/plugins/case/kibana.json | 9 + x-pack/plugins/case/server/config.ts | 15 ++ x-pack/plugins/case/server/constants.ts | 8 + x-pack/plugins/case/server/index.ts | 14 ++ x-pack/plugins/case/server/plugin.ts | 63 ++++++ .../routes/api/__fixtures__/authc_mock.ts | 35 ++++ .../__fixtures__/create_mock_so_repository.ts | 77 +++++++ .../server/routes/api/__fixtures__/index.ts | 11 + .../routes/api/__fixtures__/mock_router.ts | 34 ++++ .../api/__fixtures__/mock_saved_objects.ts | 143 +++++++++++++ .../routes/api/__fixtures__/route_contexts.ts | 17 ++ .../routes/api/__tests__/delete_case.test.ts | 83 ++++++++ .../api/__tests__/delete_comment.test.ts | 53 +++++ .../api/__tests__/get_all_cases.test.ts | 34 ++++ .../routes/api/__tests__/get_case.test.ts | 101 +++++++++ .../routes/api/__tests__/get_comment.test.ts | 51 +++++ .../routes/api/__tests__/post_case.test.ts | 82 ++++++++ .../routes/api/__tests__/post_comment.test.ts | 97 +++++++++ .../routes/api/__tests__/update_case.test.ts | 59 ++++++ .../api/__tests__/update_comment.test.ts | 59 ++++++ .../case/server/routes/api/delete_case.ts | 56 +++++ .../case/server/routes/api/delete_comment.ts | 34 ++++ .../routes/api/get_all_case_comments.ts | 33 +++ .../case/server/routes/api/get_all_cases.ts | 27 +++ .../case/server/routes/api/get_case.ts | 49 +++++ .../case/server/routes/api/get_comment.ts | 33 +++ .../plugins/case/server/routes/api/index.ts | 36 ++++ .../case/server/routes/api/post_case.ts | 40 ++++ .../case/server/routes/api/post_comment.ts | 62 ++++++ .../plugins/case/server/routes/api/schema.ts | 44 ++++ .../plugins/case/server/routes/api/types.ts | 36 ++++ .../case/server/routes/api/update_case.ts | 36 ++++ .../case/server/routes/api/update_comment.ts | 36 ++++ .../plugins/case/server/routes/api/utils.ts | 48 +++++ x-pack/plugins/case/server/services/index.ts | 192 ++++++++++++++++++ x-pack/plugins/security/server/index.ts | 2 + 38 files changed, 1909 insertions(+) create mode 100644 x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts create mode 100644 x-pack/plugins/case/README.md create mode 100644 x-pack/plugins/case/kibana.json create mode 100644 x-pack/plugins/case/server/config.ts create mode 100644 x-pack/plugins/case/server/constants.ts create mode 100644 x-pack/plugins/case/server/index.ts create mode 100644 x-pack/plugins/case/server/plugin.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/index.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/delete_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/delete_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_all_case_comments.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_all_cases.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/index.ts create mode 100644 x-pack/plugins/case/server/routes/api/post_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/post_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/schema.ts create mode 100644 x-pack/plugins/case/server/routes/api/types.ts create mode 100644 x-pack/plugins/case/server/routes/api/update_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/update_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/utils.ts create mode 100644 x-pack/plugins/case/server/services/index.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts new file mode 100644 index 0000000000000..bd73805600a33 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @typescript-eslint/no-empty-interface */ +/* eslint-disable @typescript-eslint/camelcase */ +import { + NewCaseFormatted, + NewCommentFormatted, +} from '../../../../../../../x-pack/plugins/case/server'; +import { ElasticsearchMappingOf } from '../../utils/typed_elasticsearch_mappings'; + +// Temporary file to write mappings for case +// while Saved Object Mappings API is programmed for the NP +// See: https://github.com/elastic/kibana/issues/50309 + +export const caseSavedObjectType = 'case-workflow'; +export const caseCommentSavedObjectType = 'case-workflow-comment'; + +export const caseSavedObjectMappings: { + [caseSavedObjectType]: ElasticsearchMappingOf; +} = { + [caseSavedObjectType]: { + properties: { + assignees: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + created_at: { + type: 'date', + }, + description: { + type: 'text', + }, + title: { + type: 'keyword', + }, + created_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + state: { + type: 'keyword', + }, + tags: { + type: 'keyword', + }, + case_type: { + type: 'keyword', + }, + }, + }, +}; + +export const caseCommentSavedObjectMappings: { + [caseCommentSavedObjectType]: ElasticsearchMappingOf; +} = { + [caseCommentSavedObjectType]: { + properties: { + comment: { + type: 'text', + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + full_name: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/case/README.md b/x-pack/plugins/case/README.md new file mode 100644 index 0000000000000..c0acb87835207 --- /dev/null +++ b/x-pack/plugins/case/README.md @@ -0,0 +1,9 @@ +# Case Workflow + +*Experimental Feature* + +Elastic is developing a Case Management Workflow. Follow our progress: + +- [Case API Documentation](https://documenter.getpostman.com/view/172706/SW7c2SuF?version=latest) +- [Github Meta](https://github.com/elastic/kibana/issues/50103) + diff --git a/x-pack/plugins/case/kibana.json b/x-pack/plugins/case/kibana.json new file mode 100644 index 0000000000000..23e3cc789ad3b --- /dev/null +++ b/x-pack/plugins/case/kibana.json @@ -0,0 +1,9 @@ +{ + "configPath": ["xpack", "case"], + "id": "case", + "kibanaVersion": "kibana", + "requiredPlugins": ["security"], + "server": true, + "ui": false, + "version": "8.0.0" +} diff --git a/x-pack/plugins/case/server/config.ts b/x-pack/plugins/case/server/config.ts new file mode 100644 index 0000000000000..a7cb117198f9b --- /dev/null +++ b/x-pack/plugins/case/server/config.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 { schema, TypeOf } from '@kbn/config-schema'; + +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), + indexPattern: schema.string({ defaultValue: '.case-test-2' }), + secret: schema.string({ defaultValue: 'Cool secret huh?' }), +}); + +export type ConfigType = TypeOf; diff --git a/x-pack/plugins/case/server/constants.ts b/x-pack/plugins/case/server/constants.ts new file mode 100644 index 0000000000000..276dcd135254a --- /dev/null +++ b/x-pack/plugins/case/server/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const CASE_SAVED_OBJECT = 'case-workflow'; +export const CASE_COMMENT_SAVED_OBJECT = 'case-workflow-comment'; diff --git a/x-pack/plugins/case/server/index.ts b/x-pack/plugins/case/server/index.ts new file mode 100644 index 0000000000000..3963debea9795 --- /dev/null +++ b/x-pack/plugins/case/server/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from '../../../../src/core/server'; +import { ConfigSchema } from './config'; +import { CasePlugin } from './plugin'; +export { NewCaseFormatted, NewCommentFormatted } from './routes/api/types'; + +export const config = { schema: ConfigSchema }; +export const plugin = (initializerContext: PluginInitializerContext) => + new CasePlugin(initializerContext); diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts new file mode 100644 index 0000000000000..c52461cade058 --- /dev/null +++ b/x-pack/plugins/case/server/plugin.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 { first, map } from 'rxjs/operators'; +import { CoreSetup, Logger, PluginInitializerContext } from 'kibana/server'; +import { ConfigType } from './config'; +import { initCaseApi } from './routes/api'; +import { CaseService } from './services'; +import { PluginSetupContract as SecurityPluginSetup } from '../../security/server'; + +function createConfig$(context: PluginInitializerContext) { + return context.config.create().pipe(map(config => config)); +} + +export interface PluginsSetup { + security: SecurityPluginSetup; +} + +export class CasePlugin { + private readonly log: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.log = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup, plugins: PluginsSetup) { + const config = await createConfig$(this.initializerContext) + .pipe(first()) + .toPromise(); + + if (!config.enabled) { + return; + } + const service = new CaseService(this.log); + + this.log.debug( + `Setting up Case Workflow with core contract [${Object.keys( + core + )}] and plugins [${Object.keys(plugins)}]` + ); + + const caseService = await service.setup({ + authentication: plugins.security.authc, + }); + + const router = core.http.createRouter(); + initCaseApi({ + caseService, + router, + }); + } + + public start() { + this.log.debug(`Starting Case Workflow`); + } + + public stop() { + this.log.debug(`Stopping Case Workflow`); + } +} diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts new file mode 100644 index 0000000000000..94ce9627b9ac6 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Authentication } from '../../../../../security/server'; + +const getCurrentUser = jest.fn().mockReturnValue({ + username: 'awesome', + full_name: 'Awesome D00d', +}); +const getCurrentUserThrow = jest.fn().mockImplementation(() => { + throw new Error('Bad User - the user is not authenticated'); +}); + +export const authenticationMock = { + create: (): jest.Mocked => ({ + login: jest.fn(), + createAPIKey: jest.fn(), + getCurrentUser, + invalidateAPIKey: jest.fn(), + isAuthenticated: jest.fn(), + logout: jest.fn(), + getSessionInfo: jest.fn(), + }), + createInvalid: (): jest.Mocked => ({ + login: jest.fn(), + createAPIKey: jest.fn(), + getCurrentUser: getCurrentUserThrow, + invalidateAPIKey: jest.fn(), + isAuthenticated: jest.fn(), + logout: jest.fn(), + getSessionInfo: jest.fn(), + }), +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts new file mode 100644 index 0000000000000..360c6de67b2a8 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../../constants'; + +export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { + const mockSavedObjectsClientContract = ({ + get: jest.fn((type, id) => { + const result = savedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + }), + find: jest.fn(findArgs => { + if (findArgs.hasReference && findArgs.hasReference.id === 'bad-guy') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + return { + total: savedObject.length, + saved_objects: savedObject, + }; + }), + create: jest.fn((type, attributes, references) => { + if (attributes.description === 'Throw an error' || attributes.comment === 'Throw an error') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + if (type === CASE_COMMENT_SAVED_OBJECT) { + return { + type, + id: 'mock-comment', + attributes, + ...references, + updated_at: '2019-12-02T22:48:08.327Z', + version: 'WzksMV0=', + }; + } + return { + type, + id: 'mock-it', + attributes, + references: [], + updated_at: '2019-12-02T22:48:08.327Z', + version: 'WzksMV0=', + }; + }), + update: jest.fn((type, id, attributes) => { + if (!savedObject.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return { + id, + type, + updated_at: '2019-11-22T22:50:55.191Z', + version: 'WzE3LDFd', + attributes, + }; + }), + delete: jest.fn((type: string, id: string) => { + const result = savedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + if (type === 'case-workflow-comment' && id === 'bad-guy') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + return {}; + }), + deleteByNamespace: jest.fn(), + } as unknown) as jest.Mocked; + + return mockSavedObjectsClientContract; +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts new file mode 100644 index 0000000000000..e1fec2d6b229c --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { mockCases, mockCasesErrorTriggerData, mockCaseComments } from './mock_saved_objects'; +export { createMockSavedObjectsRepository } from './create_mock_so_repository'; +export { createRouteContext } from './route_contexts'; +export { authenticationMock } from './authc_mock'; +export { createRoute } from './mock_router'; 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 new file mode 100644 index 0000000000000..84889c3ac49be --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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 } from '../../../services'; +import { authenticationMock } from '../__fixtures__'; +import { RouteDeps } from '../index'; + +export const createRoute = async ( + api: (deps: RouteDeps) => void, + method: 'get' | 'post' | 'delete', + badAuth = false +) => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + + const log = loggingServiceMock.create().get('case'); + + const service = new CaseService(log); + const caseService = await service.setup({ + authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), + }); + + api({ + router, + caseService, + }); + + return router[method].mock.calls[0][1]; +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts new file mode 100644 index 0000000000000..d59f0977e6993 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const mockCases = [ + { + type: 'case-workflow', + id: 'mock-id-1', + attributes: { + created_at: 1574718888885, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T21:54:48.952Z', + version: 'WzAsMV0=', + }, + { + type: 'case-workflow', + id: 'mock-id-2', + attributes: { + created_at: 1574721120834, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'Oh no, a bad meanie destroying data!', + title: 'Damaging Data Destruction Detected', + state: 'open', + tags: ['Data Destruction'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T22:32:00.900Z', + version: 'WzQsMV0=', + }, + { + type: 'case-workflow', + id: 'mock-id-3', + attributes: { + created_at: 1574721137881, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + title: 'Another bad one', + state: 'open', + tags: ['LOLBins'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T22:32:17.947Z', + version: 'WzUsMV0=', + }, +]; + +export const mockCasesErrorTriggerData = [ + { + id: 'valid-id', + }, + { + id: 'bad-guy', + }, +]; + +export const mockCaseComments = [ + { + type: 'case-workflow-comment', + id: 'mock-comment-1', + attributes: { + comment: 'Wow, good luck catching that bad meanie!', + created_at: 1574718900112, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-1', + }, + ], + updated_at: '2019-11-25T21:55:00.177Z', + version: 'WzEsMV0=', + }, + { + type: 'case-workflow-comment', + id: 'mock-comment-2', + attributes: { + comment: 'Well I decided to update my comment. So what? Deal with it.', + created_at: 1574718902724, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-1', + }, + ], + updated_at: '2019-11-25T21:55:14.633Z', + version: 'WzMsMV0=', + }, + { + type: 'case-workflow-comment', + id: 'mock-comment-3', + attributes: { + comment: 'Wow, good luck catching that bad meanie!', + created_at: 1574721150542, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-3', + }, + ], + updated_at: '2019-11-25T22:32:30.608Z', + version: 'WzYsMV0=', + }, +]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts new file mode 100644 index 0000000000000..b1881e394e796 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandlerContext } from 'src/core/server'; + +export const createRouteContext = (client: any) => { + return ({ + core: { + savedObjects: { + client, + }, + }, + } as unknown) as RequestHandlerContext; +}; diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts new file mode 100644 index 0000000000000..9ea42ba42406b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts @@ -0,0 +1,83 @@ +/* + * 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 { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initDeleteCaseApi } from '../delete_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('DELETE case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initDeleteCaseApi, 'delete'); + }); + it(`deletes the case. responds with 204`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'mock-id-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(204); + }); + it(`returns an error when thrown from deleteCase service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'not-real', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + }); + it(`returns an error when thrown from getAllCaseComments service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'bad-guy', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); + it(`returns an error when thrown from deleteComment service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'valid-id', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts new file mode 100644 index 0000000000000..e50b3cbaa9c9a --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initDeleteCommentApi } from '../delete_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('DELETE comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initDeleteCommentApi, 'delete'); + }); + it(`deletes the comment. responds with 204`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{comment_id}', + method: 'delete', + params: { + comment_id: 'mock-id-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(204); + }); + it(`returns an error when thrown from deleteComment service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{comment_id}', + method: 'delete', + params: { + comment_id: 'bad-guy', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts new file mode 100644 index 0000000000000..2f8a229c08f29 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initGetAllCasesApi } from '../get_all_cases'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET all cases', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetAllCasesApi, 'get'); + }); + it(`returns the case without case comments when includeComments is false`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'get', + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.saved_objects).toHaveLength(3); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts new file mode 100644 index 0000000000000..3c5f8e52d1946 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initGetCaseApi } from '../get_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetCaseApi, 'get'); + }); + it(`returns the case without case comments when includeComments is false`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'mock-id-1', + }, + method: 'get', + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual(mockCases.find(s => s.id === 'mock-id-1')); + expect(response.payload.comments).toBeUndefined(); + }); + it(`returns an error when thrown from getCase`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'abcdefg', + }, + method: 'get', + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); + it(`returns the case with case comments when includeComments is true`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'mock-id-1', + }, + method: 'get', + query: { + includeComments: true, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.comments.saved_objects).toHaveLength(3); + }); + it(`returns an error when thrown from getAllCaseComments`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'bad-guy', + }, + method: 'get', + query: { + includeComments: true, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts new file mode 100644 index 0000000000000..9b6a1e435838b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCaseComments, +} from '../__fixtures__'; +import { initGetCommentApi } from '../get_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetCommentApi, 'get'); + }); + it(`returns the comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{id}', + method: 'get', + params: { + id: 'mock-comment-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload).toEqual(mockCaseComments.find(s => s.id === 'mock-comment-1')); + }); + it(`returns an error when getComment throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{id}', + method: 'get', + params: { + id: 'not-real', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts new file mode 100644 index 0000000000000..bb688dde4c58f --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initPostCaseApi } from '../post_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('POST cases', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initPostCaseApi, 'post'); + }); + it(`Posts a new case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-it'); + expect(response.payload.attributes.created_by.username).toEqual('awesome'); + }); + it(`Returns an error if postNewCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'Throw an error', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['error'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if user authentication throws`, async () => { + routeHandler = await createRoute(initPostCaseApi, 'post', true); + + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(500); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts new file mode 100644 index 0000000000000..0c059b7f15ea4 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initPostCommentApi } from '../post_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('POST comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initPostCommentApi, 'post'); + }); + it(`Posts a new comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-comment'); + expect(response.payload.references[0].id).toEqual('mock-id-1'); + }); + it(`Returns an error if the case does not exist`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'this-is-not-real', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if postNewCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Throw an error', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if user authentication throws`, async () => { + routeHandler = await createRoute(initPostCommentApi, 'post', true); + + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(500); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts new file mode 100644 index 0000000000000..7ed478d2e7c01 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts @@ -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. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initUpdateCaseApi } from '../update_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('UPDATE case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initUpdateCaseApi, 'post'); + }); + it(`Updates a case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + state: 'closed', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-id-1'); + expect(response.payload.attributes.state).toEqual('closed'); + }); + it(`Returns an error if updateCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'post', + params: { + id: 'mock-id-does-not-exist', + }, + body: { + state: 'closed', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts new file mode 100644 index 0000000000000..8aa84b45b7dbb --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts @@ -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. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCaseComments, +} from '../__fixtures__'; +import { initUpdateCommentApi } from '../update_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('UPDATE comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initUpdateCommentApi, 'post'); + }); + it(`Updates a comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comment/{id}', + method: 'post', + params: { + id: 'mock-comment-1', + }, + body: { + comment: 'Update my comment', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-comment-1'); + expect(response.payload.attributes.comment).toEqual('Update my comment'); + }); + it(`Returns an error if updateComment throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comment/{id}', + method: 'post', + params: { + id: 'mock-comment-does-not-exist', + }, + body: { + comment: 'Update my comment', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/delete_case.ts b/x-pack/plugins/case/server/routes/api/delete_case.ts new file mode 100644 index 0000000000000..a5ae72b8b46ff --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/delete_case.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initDeleteCaseApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + let allCaseComments; + try { + await caseService.deleteCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + allCaseComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + if (allCaseComments.saved_objects.length > 0) { + await Promise.all( + allCaseComments.saved_objects.map(({ id }) => + caseService.deleteComment({ + client: context.core.savedObjects.client, + commentId: id, + }) + ) + ); + } + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/delete_comment.ts b/x-pack/plugins/case/server/routes/api/delete_comment.ts new file mode 100644 index 0000000000000..4a540dd9fd69f --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/delete_comment.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initDeleteCommentApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/comments/{comment_id}', + validate: { + params: schema.object({ + comment_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = context.core.savedObjects.client; + try { + await caseService.deleteComment({ + client, + commentId: request.params.comment_id, + }); + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts new file mode 100644 index 0000000000000..cc4956ead1bd7 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetAllCaseCommentsApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{id}/comments', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const theComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + return response.ok({ body: theComments }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_all_cases.ts b/x-pack/plugins/case/server/routes/api/get_all_cases.ts new file mode 100644 index 0000000000000..749a183dfe980 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_all_cases.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 { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetAllCasesApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases', + validate: false, + }, + async (context, request, response) => { + try { + const cases = await caseService.getAllCases({ + client: context.core.savedObjects.client, + }); + return response.ok({ body: cases }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_case.ts b/x-pack/plugins/case/server/routes/api/get_case.ts new file mode 100644 index 0000000000000..6aad22a1ebf1b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_case.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetCaseApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + query: schema.object({ + includeComments: schema.string({ defaultValue: 'true' }), + }), + }, + }, + async (context, request, response) => { + let theCase; + const includeComments = JSON.parse(request.query.includeComments); + try { + theCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + if (!includeComments) { + return response.ok({ body: theCase }); + } + try { + const theComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + return response.ok({ body: { ...theCase, comments: theComments } }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_comment.ts b/x-pack/plugins/case/server/routes/api/get_comment.ts new file mode 100644 index 0000000000000..6fd507d89738d --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_comment.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetCommentApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/comments/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const theComment = await caseService.getComment({ + client: context.core.savedObjects.client, + commentId: request.params.id, + }); + return response.ok({ body: theComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts new file mode 100644 index 0000000000000..11ef91d539e87 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; +import { initDeleteCommentApi } from './delete_comment'; +import { initDeleteCaseApi } from './delete_case'; +import { initGetAllCaseCommentsApi } from './get_all_case_comments'; +import { initGetAllCasesApi } from './get_all_cases'; +import { initGetCaseApi } from './get_case'; +import { initGetCommentApi } from './get_comment'; +import { initPostCaseApi } from './post_case'; +import { initPostCommentApi } from './post_comment'; +import { initUpdateCaseApi } from './update_case'; +import { initUpdateCommentApi } from './update_comment'; +import { CaseServiceSetup } from '../../services'; + +export interface RouteDeps { + caseService: CaseServiceSetup; + router: IRouter; +} + +export function initCaseApi(deps: RouteDeps) { + initGetAllCaseCommentsApi(deps); + initGetAllCasesApi(deps); + initGetCaseApi(deps); + initGetCommentApi(deps); + initDeleteCaseApi(deps); + initDeleteCommentApi(deps); + initPostCaseApi(deps); + initPostCommentApi(deps); + initUpdateCaseApi(deps); + initUpdateCommentApi(deps); +} diff --git a/x-pack/plugins/case/server/routes/api/post_case.ts b/x-pack/plugins/case/server/routes/api/post_case.ts new file mode 100644 index 0000000000000..e5aa0a3548b48 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/post_case.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { formatNewCase, wrapError } from './utils'; +import { NewCaseSchema } from './schema'; +import { RouteDeps } from '.'; + +export function initPostCaseApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases', + validate: { + body: NewCaseSchema, + }, + }, + async (context, request, response) => { + let createdBy; + try { + createdBy = await caseService.getUser({ request, response }); + } catch (error) { + return response.customError(wrapError(error)); + } + + try { + const newCase = await caseService.postNewCase({ + client: context.core.savedObjects.client, + attributes: formatNewCase(request.body, { + ...createdBy, + }), + }); + return response.ok({ body: newCase }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/post_comment.ts b/x-pack/plugins/case/server/routes/api/post_comment.ts new file mode 100644 index 0000000000000..3f4592f5bb11f --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/post_comment.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { formatNewComment, wrapError } from './utils'; +import { NewCommentSchema } from './schema'; +import { RouteDeps } from '.'; +import { CASE_SAVED_OBJECT } from '../../constants'; + +export function initPostCommentApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/{id}/comment', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: NewCommentSchema, + }, + }, + async (context, request, response) => { + let createdBy; + let newComment; + try { + await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + createdBy = await caseService.getUser({ request, response }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + newComment = await caseService.postNewComment({ + client: context.core.savedObjects.client, + attributes: formatNewComment({ + newComment: request.body, + ...createdBy, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: request.params.id, + }, + ], + }); + + return response.ok({ body: newComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/schema.ts b/x-pack/plugins/case/server/routes/api/schema.ts new file mode 100644 index 0000000000000..4a4a0c3a11e36 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/schema.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +export const UserSchema = schema.object({ + username: schema.string(), + full_name: schema.maybe(schema.string()), +}); + +export const NewCommentSchema = schema.object({ + comment: schema.string(), +}); + +export const CommentSchema = schema.object({ + comment: schema.string(), + created_at: schema.number(), + created_by: UserSchema, +}); + +export const UpdatedCommentSchema = schema.object({ + comment: schema.string(), +}); + +export const NewCaseSchema = schema.object({ + assignees: schema.arrayOf(UserSchema, { defaultValue: [] }), + description: schema.string(), + title: schema.string(), + state: schema.oneOf([schema.literal('open'), schema.literal('closed')], { defaultValue: 'open' }), + tags: schema.arrayOf(schema.string(), { defaultValue: [] }), + case_type: schema.string(), +}); + +export const UpdatedCaseSchema = schema.object({ + assignees: schema.maybe(schema.arrayOf(UserSchema)), + description: schema.maybe(schema.string()), + title: schema.maybe(schema.string()), + state: schema.maybe(schema.oneOf([schema.literal('open'), schema.literal('closed')])), + tags: schema.maybe(schema.arrayOf(schema.string())), + case_type: schema.maybe(schema.string()), +}); diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts new file mode 100644 index 0000000000000..d943e4e5fd7dd --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { + CommentSchema, + NewCaseSchema, + NewCommentSchema, + UpdatedCaseSchema, + UpdatedCommentSchema, + UserSchema, +} from './schema'; + +export type NewCaseType = TypeOf; +export type NewCommentFormatted = TypeOf; +export type NewCommentType = TypeOf; +export type UpdatedCaseTyped = TypeOf; +export type UpdatedCommentType = TypeOf; +export type UserType = TypeOf; + +export interface NewCaseFormatted extends NewCaseType { + created_at: number; + created_by: UserType; +} + +export interface UpdatedCaseType { + assignees?: UpdatedCaseTyped['assignees']; + description?: UpdatedCaseTyped['description']; + title?: UpdatedCaseTyped['title']; + state?: UpdatedCaseTyped['state']; + tags?: UpdatedCaseTyped['tags']; + case_type?: UpdatedCaseTyped['case_type']; +} diff --git a/x-pack/plugins/case/server/routes/api/update_case.ts b/x-pack/plugins/case/server/routes/api/update_case.ts new file mode 100644 index 0000000000000..52c8cab0022dd --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/update_case.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { wrapError } from './utils'; +import { RouteDeps } from '.'; +import { UpdatedCaseSchema } from './schema'; + +export function initUpdateCaseApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: UpdatedCaseSchema, + }, + }, + async (context, request, response) => { + try { + const updatedCase = await caseService.updateCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + updatedAttributes: request.body, + }); + return response.ok({ body: updatedCase }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/update_comment.ts b/x-pack/plugins/case/server/routes/api/update_comment.ts new file mode 100644 index 0000000000000..e1ee6029e8e4f --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/update_comment.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { wrapError } from './utils'; +import { NewCommentSchema } from './schema'; +import { RouteDeps } from '.'; + +export function initUpdateCommentApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/comment/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: NewCommentSchema, + }, + }, + async (context, request, response) => { + try { + const updatedComment = await caseService.updateComment({ + client: context.core.savedObjects.client, + commentId: request.params.id, + updatedAttributes: request.body, + }); + return response.ok({ body: updatedComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts new file mode 100644 index 0000000000000..c6e33dbb8433b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -0,0 +1,48 @@ +/* + * 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 { boomify, isBoom } from 'boom'; +import { CustomHttpResponseOptions, ResponseError } from 'kibana/server'; +import { + NewCaseType, + NewCaseFormatted, + NewCommentType, + NewCommentFormatted, + UserType, +} from './types'; + +export const formatNewCase = ( + newCase: NewCaseType, + { full_name, username }: { full_name?: string; username: string } +): NewCaseFormatted => ({ + created_at: new Date().valueOf(), + created_by: { full_name, username }, + ...newCase, +}); + +interface NewCommentArgs { + newComment: NewCommentType; + full_name?: UserType['full_name']; + username: UserType['username']; +} +export const formatNewComment = ({ + newComment, + full_name, + username, +}: NewCommentArgs): NewCommentFormatted => ({ + ...newComment, + created_at: new Date().valueOf(), + created_by: { full_name, username }, +}); + +export function wrapError(error: any): CustomHttpResponseOptions { + const boom = isBoom(error) ? error : boomify(error); + return { + body: boom, + headers: boom.output.headers, + statusCode: boom.output.statusCode, + }; +} diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts new file mode 100644 index 0000000000000..684d905a5c71f --- /dev/null +++ b/x-pack/plugins/case/server/services/index.ts @@ -0,0 +1,192 @@ +/* + * 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 { + KibanaRequest, + KibanaResponseFactory, + Logger, + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsUpdateResponse, + SavedObjectReference, +} from 'kibana/server'; +import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../constants'; +import { + NewCaseFormatted, + NewCommentFormatted, + UpdatedCaseType, + UpdatedCommentType, +} from '../routes/api/types'; +import { + AuthenticatedUser, + PluginSetupContract as SecurityPluginSetup, +} from '../../../security/server'; + +interface ClientArgs { + client: SavedObjectsClientContract; +} + +interface GetCaseArgs extends ClientArgs { + caseId: string; +} +interface GetCommentArgs extends ClientArgs { + commentId: string; +} +interface PostCaseArgs extends ClientArgs { + attributes: NewCaseFormatted; +} + +interface PostCommentArgs extends ClientArgs { + attributes: NewCommentFormatted; + references: SavedObjectReference[]; +} +interface UpdateCaseArgs extends ClientArgs { + caseId: string; + updatedAttributes: UpdatedCaseType; +} +interface UpdateCommentArgs extends ClientArgs { + commentId: string; + updatedAttributes: UpdatedCommentType; +} + +interface GetUserArgs { + request: KibanaRequest; + response: KibanaResponseFactory; +} + +interface CaseServiceDeps { + authentication: SecurityPluginSetup['authc']; +} +export interface CaseServiceSetup { + deleteCase(args: GetCaseArgs): Promise<{}>; + deleteComment(args: GetCommentArgs): Promise<{}>; + getAllCases(args: ClientArgs): Promise; + getAllCaseComments(args: GetCaseArgs): Promise; + getCase(args: GetCaseArgs): Promise; + getComment(args: GetCommentArgs): Promise; + getUser(args: GetUserArgs): Promise; + postNewCase(args: PostCaseArgs): Promise; + postNewComment(args: PostCommentArgs): Promise; + updateCase(args: UpdateCaseArgs): Promise; + updateComment(args: UpdateCommentArgs): Promise; +} + +export class CaseService { + constructor(private readonly log: Logger) {} + public setup = async ({ authentication }: CaseServiceDeps): Promise => ({ + deleteCase: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await client.delete(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on GET case ${caseId}: ${error}`); + throw error; + } + }, + deleteComment: async ({ client, commentId }: GetCommentArgs) => { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + }, + getCase: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await client.get(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on GET case ${caseId}: ${error}`); + throw error; + } + }, + getComment: async ({ client, commentId }: GetCommentArgs) => { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + }, + getAllCases: async ({ client }: ClientArgs) => { + try { + this.log.debug(`Attempting to GET all cases`); + return await client.find({ type: CASE_SAVED_OBJECT }); + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + }, + getAllCaseComments: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET all comments for case ${caseId}`); + return await client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for case ${caseId}: ${error}`); + throw error; + } + }, + getUser: async ({ request, response }: GetUserArgs) => { + let user; + try { + this.log.debug(`Attempting to authenticate a user`); + user = await authentication!.getCurrentUser(request); + } catch (error) { + this.log.debug(`Error on GET user: ${error}`); + throw error; + } + if (!user) { + this.log.debug(`Error on GET user: Bad User`); + throw new Error('Bad User - the user is not authenticated'); + } + return user; + }, + postNewCase: async ({ client, attributes }: PostCaseArgs) => { + try { + this.log.debug(`Attempting to POST a new case`); + return await client.create(CASE_SAVED_OBJECT, { ...attributes }); + } catch (error) { + this.log.debug(`Error on POST a new case: ${error}`); + throw error; + } + }, + postNewComment: async ({ client, attributes, references }: PostCommentArgs) => { + try { + this.log.debug(`Attempting to POST a new comment`); + return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); + } catch (error) { + this.log.debug(`Error on POST a new comment: ${error}`); + throw error; + } + }, + updateCase: async ({ client, caseId, updatedAttributes }: UpdateCaseArgs) => { + try { + this.log.debug(`Attempting to UPDATE case ${caseId}`); + return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }); + } catch (error) { + this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); + throw error; + } + }, + updateComment: async ({ client, commentId, updatedAttributes }: UpdateCommentArgs) => { + try { + this.log.debug(`Attempting to UPDATE comment ${commentId}`); + return await client.update(CASE_COMMENT_SAVED_OBJECT, commentId, { + ...updatedAttributes, + }); + } catch (error) { + this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); + throw error; + } + }, + }); +} diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 33f554be5caa3..17e49b8cf40d3 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -17,6 +17,7 @@ import { Plugin, PluginSetupContract, PluginSetupDependencies } from './plugin'; // These exports are part of public Security plugin contract, any change in signature of exported // functions or removal of exports should be considered as a breaking change. export { + Authentication, AuthenticationResult, DeauthenticationResult, CreateAPIKeyResult, @@ -24,6 +25,7 @@ export { InvalidateAPIKeyResult, } from './authentication'; export { PluginSetupContract }; +export { AuthenticatedUser } from '../common/model'; export const config: PluginConfigDescriptor> = { schema: ConfigSchema, From e1e1d964c67d77d20d0d309bf09121bb9fc8e5ac Mon Sep 17 00:00:00 2001 From: Phillip Burch Date: Wed, 8 Jan 2020 16:37:37 -0600 Subject: [PATCH 26/63] Reset region and Account when switching inventory (#54287) --- .../components/waffle/waffle_inventory_switcher.tsx | 8 +++++++- .../public/pages/infrastructure/snapshot/toolbar.tsx | 11 ++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx index bdd08ab6b366f..785531db2ff5e 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx @@ -28,6 +28,8 @@ interface WaffleInventorySwitcherProps { changeNodeType: (nodeType: InfraNodeType) => void; changeGroupBy: (groupBy: InfraSnapshotGroupbyInput[]) => void; changeMetric: (metric: InfraSnapshotMetricInput) => void; + changeAccount: (id: string) => void; + changeRegion: (name: string) => void; } const getDisplayNameForType = (type: InventoryItemType) => { @@ -39,6 +41,8 @@ export const WaffleInventorySwitcher: React.FC = ( changeNodeType, changeGroupBy, changeMetric, + changeAccount, + changeRegion, nodeType, }) => { const [isOpen, setIsOpen] = useState(false); @@ -49,12 +53,14 @@ export const WaffleInventorySwitcher: React.FC = ( closePopover(); changeNodeType(targetNodeType); changeGroupBy([]); + changeAccount(''); + changeRegion(''); const inventoryModel = findInventoryModel(targetNodeType); changeMetric({ type: inventoryModel.metrics.defaultSnapshot as InfraSnapshotMetricType, }); }, - [closePopover, changeNodeType, changeGroupBy, changeMetric] + [closePopover, changeNodeType, changeGroupBy, changeMetric, changeAccount, changeRegion] ); const goToHost = useCallback(() => goToNodeType('host' as InfraNodeType), [goToNodeType]); const goToK8 = useCallback(() => goToNodeType('pod' as InfraNodeType), [goToNodeType]); diff --git a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx index a828cd207aa5b..a5780f44050e1 100644 --- a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx @@ -23,12 +23,21 @@ export const SnapshotToolbar = () => ( - {({ changeMetric, changeNodeType, changeGroupBy, nodeType }) => ( + {({ + changeMetric, + changeNodeType, + changeGroupBy, + changeAccount, + changeRegion, + nodeType, + }) => ( )} From 9282f19bf54df2d2ccdfaaf526626e94f29cc45f Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Wed, 8 Jan 2020 17:43:10 -0600 Subject: [PATCH 27/63] Management - New platform api (#52579) * implement management new platform api --- .../kibana/public/management/index.js | 13 +- src/legacy/ui/public/_index.scss | 1 + src/legacy/ui/public/management/_index.scss | 1 - .../__snapshots__/sidebar_nav.test.ts.snap | 24 --- .../management/components/sidebar_nav.tsx | 107 ---------- src/legacy/ui/public/management/index.js | 2 - src/plugins/management/kibana.json | 2 +- .../management_app.test.tsx.snap | 11 + .../management/public/components/_index.scss | 1 + .../management/public/components/index.ts | 21 ++ .../components/management_chrome}/index.ts | 2 +- .../management_chrome/management_chrome.tsx | 57 +++++ .../management_sidebar_nav.test.ts.snap | 95 +++++++++ .../management_sidebar_nav}/_index.scss | 0 .../management_sidebar_nav}/_sidebar_nav.scss | 2 +- .../management_sidebar_nav/index.ts | 20 ++ .../management_sidebar_nav.test.ts} | 26 ++- .../management_sidebar_nav.tsx | 200 ++++++++++++++++++ src/plugins/management/public/index.ts | 5 +- src/plugins/management/public/legacy/index.js | 3 +- .../management/public/legacy/section.js | 8 +- .../management/public/legacy/section.test.js | 40 ++-- .../public/legacy/sections_register.js | 72 ++++--- .../management/public/management_app.test.tsx | 66 ++++++ .../management/public/management_app.tsx | 102 +++++++++ .../public/management_section.test.ts | 65 ++++++ .../management/public/management_section.ts | 78 +++++++ .../public/management_service.test.ts | 55 +++++ .../management/public/management_service.ts | 103 +++++++++ src/plugins/management/public/plugin.ts | 24 ++- src/plugins/management/public/types.ts | 76 +++++++ test/plugin_functional/config.js | 1 + .../management_test_plugin/kibana.json | 9 + .../management_test_plugin/package.json | 17 ++ .../management_test_plugin/public/index.ts | 28 +++ .../management_test_plugin/public/plugin.tsx | 73 +++++++ .../management_test_plugin/tsconfig.json | 14 ++ .../test_suites/management/index.js | 24 +++ .../management/management_plugin.js | 40 ++++ x-pack/legacy/plugins/infra/types/eui.d.ts | 2 +- .../management/management_service.test.ts | 10 + .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- 43 files changed, 1296 insertions(+), 212 deletions(-) delete mode 100644 src/legacy/ui/public/management/_index.scss delete mode 100644 src/legacy/ui/public/management/components/__snapshots__/sidebar_nav.test.ts.snap delete mode 100644 src/legacy/ui/public/management/components/sidebar_nav.tsx create mode 100644 src/plugins/management/public/__snapshots__/management_app.test.tsx.snap create mode 100644 src/plugins/management/public/components/_index.scss create mode 100644 src/plugins/management/public/components/index.ts rename src/{legacy/ui/public/management/components => plugins/management/public/components/management_chrome}/index.ts (93%) create mode 100644 src/plugins/management/public/components/management_chrome/management_chrome.tsx create mode 100644 src/plugins/management/public/components/management_sidebar_nav/__snapshots__/management_sidebar_nav.test.ts.snap rename src/{legacy/ui/public/management/components => plugins/management/public/components/management_sidebar_nav}/_index.scss (100%) rename src/{legacy/ui/public/management/components => plugins/management/public/components/management_sidebar_nav}/_sidebar_nav.scss (88%) create mode 100644 src/plugins/management/public/components/management_sidebar_nav/index.ts rename src/{legacy/ui/public/management/components/sidebar_nav.test.ts => plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.test.ts} (75%) create mode 100644 src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx create mode 100644 src/plugins/management/public/management_app.test.tsx create mode 100644 src/plugins/management/public/management_app.tsx create mode 100644 src/plugins/management/public/management_section.test.ts create mode 100644 src/plugins/management/public/management_section.ts create mode 100644 src/plugins/management/public/management_service.test.ts create mode 100644 src/plugins/management/public/management_service.ts create mode 100644 test/plugin_functional/plugins/management_test_plugin/kibana.json create mode 100644 test/plugin_functional/plugins/management_test_plugin/package.json create mode 100644 test/plugin_functional/plugins/management_test_plugin/public/index.ts create mode 100644 test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx create mode 100644 test/plugin_functional/plugins/management_test_plugin/tsconfig.json create mode 100644 test/plugin_functional/test_suites/management/index.js create mode 100644 test/plugin_functional/test_suites/management/management_plugin.js diff --git a/src/legacy/core_plugins/kibana/public/management/index.js b/src/legacy/core_plugins/kibana/public/management/index.js index 5323fb2dac2d2..d62770956b88e 100644 --- a/src/legacy/core_plugins/kibana/public/management/index.js +++ b/src/legacy/core_plugins/kibana/public/management/index.js @@ -28,7 +28,8 @@ import { I18nContext } from 'ui/i18n'; import { uiModules } from 'ui/modules'; import appTemplate from './app.html'; import landingTemplate from './landing.html'; -import { management, SidebarNav, MANAGEMENT_BREADCRUMB } from 'ui/management'; +import { management, MANAGEMENT_BREADCRUMB } from 'ui/management'; +import { ManagementSidebarNav } from '../../../../../plugins/management/public'; import { FeatureCatalogueRegistryProvider, FeatureCatalogueCategory, @@ -42,6 +43,7 @@ import { EuiIcon, EuiHorizontalRule, } from '@elastic/eui'; +import { npStart } from 'ui/new_platform'; const SIDENAV_ID = 'management-sidenav'; const LANDING_ID = 'management-landing'; @@ -102,7 +104,7 @@ export function updateLandingPage(version) { ); } -export function updateSidebar(items, id) { +export function updateSidebar(legacySections, id) { const node = document.getElementById(SIDENAV_ID); if (!node) { return; @@ -110,7 +112,12 @@ export function updateSidebar(items, id) { render( - + , node ); diff --git a/src/legacy/ui/public/_index.scss b/src/legacy/ui/public/_index.scss index 98675402b43cc..747ad025ef691 100644 --- a/src/legacy/ui/public/_index.scss +++ b/src/legacy/ui/public/_index.scss @@ -20,6 +20,7 @@ @import './saved_objects/index'; @import './share/index'; @import './style_compile/index'; +@import '../../../plugins/management/public/components/index'; // The following are prefixed with "vis" diff --git a/src/legacy/ui/public/management/_index.scss b/src/legacy/ui/public/management/_index.scss deleted file mode 100644 index 30ac0c9fe9b27..0000000000000 --- a/src/legacy/ui/public/management/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './components/index'; \ No newline at end of file diff --git a/src/legacy/ui/public/management/components/__snapshots__/sidebar_nav.test.ts.snap b/src/legacy/ui/public/management/components/__snapshots__/sidebar_nav.test.ts.snap deleted file mode 100644 index 3364bee33a544..0000000000000 --- a/src/legacy/ui/public/management/components/__snapshots__/sidebar_nav.test.ts.snap +++ /dev/null @@ -1,24 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Management filters and filters and maps section objects into SidebarNav items 1`] = ` -Array [ - Object { - "data-test-subj": "activeSection", - "href": undefined, - "icon": null, - "id": "activeSection", - "isSelected": false, - "items": Array [ - Object { - "data-test-subj": "item", - "href": undefined, - "icon": null, - "id": "item", - "isSelected": false, - "name": "item", - }, - ], - "name": "activeSection", - }, -] -`; diff --git a/src/legacy/ui/public/management/components/sidebar_nav.tsx b/src/legacy/ui/public/management/components/sidebar_nav.tsx deleted file mode 100644 index cd3d85090dce0..0000000000000 --- a/src/legacy/ui/public/management/components/sidebar_nav.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { EuiIcon, EuiSideNav, IconType, EuiScreenReaderOnly } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { IndexedArray } from 'ui/indexed_array'; - -interface Subsection { - disabled: boolean; - visible: boolean; - id: string; - display: string; - url?: string; - icon?: IconType; -} -interface Section extends Subsection { - visibleItems: IndexedArray; -} - -const sectionVisible = (section: Subsection) => !section.disabled && section.visible; -const sectionToNav = (selectedId: string) => ({ display, id, url, icon }: Subsection) => ({ - id, - name: display, - icon: icon ? : null, - isSelected: selectedId === id, - href: url, - 'data-test-subj': id, -}); - -export const sideNavItems = (sections: Section[], selectedId: string) => - sections - .filter(sectionVisible) - .filter(section => section.visibleItems.filter(sectionVisible).length) - .map(section => ({ - items: section.visibleItems.filter(sectionVisible).map(sectionToNav(selectedId)), - ...sectionToNav(selectedId)(section), - })); - -interface SidebarNavProps { - sections: Section[]; - selectedId: string; -} - -interface SidebarNavState { - isSideNavOpenOnMobile: boolean; -} - -export class SidebarNav extends React.Component { - constructor(props: SidebarNavProps) { - super(props); - this.state = { - isSideNavOpenOnMobile: false, - }; - } - - public render() { - const HEADER_ID = 'management-nav-header'; - - return ( - <> - -

- {i18n.translate('common.ui.management.nav.label', { - defaultMessage: 'Management', - })} -

- - - - ); - } - - private renderMobileTitle() { - return ; - } - - private toggleOpenOnMobile = () => { - this.setState({ - isSideNavOpenOnMobile: !this.state.isSideNavOpenOnMobile, - }); - }; -} diff --git a/src/legacy/ui/public/management/index.js b/src/legacy/ui/public/management/index.js index ed8ddb65315e2..b2f1946dbc59c 100644 --- a/src/legacy/ui/public/management/index.js +++ b/src/legacy/ui/public/management/index.js @@ -23,8 +23,6 @@ export { PAGE_FOOTER_COMPONENT, } from '../../../core_plugins/kibana/public/management/sections/settings/components/default_component_registry'; export { registerSettingsComponent } from '../../../core_plugins/kibana/public/management/sections/settings/components/component_registry'; -export { SidebarNav } from './components'; export { MANAGEMENT_BREADCRUMB } from './breadcrumbs'; - import { npStart } from 'ui/new_platform'; export const management = npStart.plugins.management.legacy; diff --git a/src/plugins/management/kibana.json b/src/plugins/management/kibana.json index 755a387afbd05..80135f1bfb6c8 100644 --- a/src/plugins/management/kibana.json +++ b/src/plugins/management/kibana.json @@ -3,5 +3,5 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": [] + "requiredPlugins": ["kibana_legacy"] } diff --git a/src/plugins/management/public/__snapshots__/management_app.test.tsx.snap b/src/plugins/management/public/__snapshots__/management_app.test.tsx.snap new file mode 100644 index 0000000000000..7f13472ee02ee --- /dev/null +++ b/src/plugins/management/public/__snapshots__/management_app.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Management app can mount and unmount 1`] = ` +
+
+ Test App - Hello world! +
+
+`; + +exports[`Management app can mount and unmount 2`] = `
`; diff --git a/src/plugins/management/public/components/_index.scss b/src/plugins/management/public/components/_index.scss new file mode 100644 index 0000000000000..df0ebb48803d9 --- /dev/null +++ b/src/plugins/management/public/components/_index.scss @@ -0,0 +1 @@ +@import './management_sidebar_nav/index'; diff --git a/src/plugins/management/public/components/index.ts b/src/plugins/management/public/components/index.ts new file mode 100644 index 0000000000000..2650d23d3c25c --- /dev/null +++ b/src/plugins/management/public/components/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { ManagementSidebarNav } from './management_sidebar_nav'; +export { ManagementChrome } from './management_chrome'; diff --git a/src/legacy/ui/public/management/components/index.ts b/src/plugins/management/public/components/management_chrome/index.ts similarity index 93% rename from src/legacy/ui/public/management/components/index.ts rename to src/plugins/management/public/components/management_chrome/index.ts index e3a18ec4e2698..b82c1af871be7 100644 --- a/src/legacy/ui/public/management/components/index.ts +++ b/src/plugins/management/public/components/management_chrome/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { SidebarNav } from './sidebar_nav'; +export { ManagementChrome } from './management_chrome'; diff --git a/src/plugins/management/public/components/management_chrome/management_chrome.tsx b/src/plugins/management/public/components/management_chrome/management_chrome.tsx new file mode 100644 index 0000000000000..7e5cabd32e48f --- /dev/null +++ b/src/plugins/management/public/components/management_chrome/management_chrome.tsx @@ -0,0 +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. + */ + +import * as React from 'react'; +import { EuiPage, EuiPageBody } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ManagementSidebarNav } from '../management_sidebar_nav'; +import { LegacySection } from '../../types'; +import { ManagementSection } from '../../management_section'; + +interface Props { + getSections: () => ManagementSection[]; + legacySections: LegacySection[]; + selectedId: string; + onMounted: (element: HTMLDivElement) => void; +} + +export class ManagementChrome extends React.Component { + private container = React.createRef(); + componentDidMount() { + if (this.container.current) { + this.props.onMounted(this.container.current); + } + } + render() { + return ( + + + + +
+ + + + ); + } +} diff --git a/src/plugins/management/public/components/management_sidebar_nav/__snapshots__/management_sidebar_nav.test.ts.snap b/src/plugins/management/public/components/management_sidebar_nav/__snapshots__/management_sidebar_nav.test.ts.snap new file mode 100644 index 0000000000000..e7225b356ed68 --- /dev/null +++ b/src/plugins/management/public/components/management_sidebar_nav/__snapshots__/management_sidebar_nav.test.ts.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Management adds legacy apps to existing SidebarNav sections 1`] = ` +Array [ + Object { + "data-test-subj": "activeSection", + "icon": null, + "id": "activeSection", + "items": Array [ + Object { + "data-test-subj": "item", + "href": undefined, + "id": "item", + "isSelected": false, + "name": "item", + "order": undefined, + }, + ], + "name": "activeSection", + "order": 10, + }, + Object { + "data-test-subj": "no-active-items", + "icon": null, + "id": "no-active-items", + "items": Array [ + Object { + "data-test-subj": "disabled", + "href": undefined, + "id": "disabled", + "isSelected": false, + "name": "disabled", + "order": undefined, + }, + Object { + "data-test-subj": "notVisible", + "href": undefined, + "id": "notVisible", + "isSelected": false, + "name": "notVisible", + "order": undefined, + }, + ], + "name": "No active items", + "order": 10, + }, +] +`; + +exports[`Management maps legacy sections and apps into SidebarNav items 1`] = ` +Array [ + Object { + "data-test-subj": "no-active-items", + "icon": null, + "id": "no-active-items", + "items": Array [ + Object { + "data-test-subj": "disabled", + "href": undefined, + "id": "disabled", + "isSelected": false, + "name": "disabled", + "order": undefined, + }, + Object { + "data-test-subj": "notVisible", + "href": undefined, + "id": "notVisible", + "isSelected": false, + "name": "notVisible", + "order": undefined, + }, + ], + "name": "No active items", + "order": 10, + }, + Object { + "data-test-subj": "activeSection", + "icon": null, + "id": "activeSection", + "items": Array [ + Object { + "data-test-subj": "item", + "href": undefined, + "id": "item", + "isSelected": false, + "name": "item", + "order": undefined, + }, + ], + "name": "activeSection", + "order": 10, + }, +] +`; diff --git a/src/legacy/ui/public/management/components/_index.scss b/src/plugins/management/public/components/management_sidebar_nav/_index.scss similarity index 100% rename from src/legacy/ui/public/management/components/_index.scss rename to src/plugins/management/public/components/management_sidebar_nav/_index.scss diff --git a/src/legacy/ui/public/management/components/_sidebar_nav.scss b/src/plugins/management/public/components/management_sidebar_nav/_sidebar_nav.scss similarity index 88% rename from src/legacy/ui/public/management/components/_sidebar_nav.scss rename to src/plugins/management/public/components/management_sidebar_nav/_sidebar_nav.scss index 0c2b2bc228b2c..cf88ed9b0a88b 100644 --- a/src/legacy/ui/public/management/components/_sidebar_nav.scss +++ b/src/plugins/management/public/components/management_sidebar_nav/_sidebar_nav.scss @@ -1,4 +1,4 @@ -.mgtSidebarNav { +.mgtSideBarNav { width: 192px; } diff --git a/src/plugins/management/public/components/management_sidebar_nav/index.ts b/src/plugins/management/public/components/management_sidebar_nav/index.ts new file mode 100644 index 0000000000000..79142fdb69a74 --- /dev/null +++ b/src/plugins/management/public/components/management_sidebar_nav/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { ManagementSidebarNav } from './management_sidebar_nav'; diff --git a/src/legacy/ui/public/management/components/sidebar_nav.test.ts b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.test.ts similarity index 75% rename from src/legacy/ui/public/management/components/sidebar_nav.test.ts rename to src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.test.ts index e02cc7d2901b6..e04e0a7572612 100644 --- a/src/legacy/ui/public/management/components/sidebar_nav.test.ts +++ b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.test.ts @@ -17,8 +17,8 @@ * under the License. */ -import { IndexedArray } from '../../indexed_array'; -import { sideNavItems } from '../components/sidebar_nav'; +import { IndexedArray } from '../../../../../legacy/ui/public/indexed_array'; +import { mergeLegacyItems } from './management_sidebar_nav'; const toIndexedArray = (initialSet: any[]) => new IndexedArray({ @@ -30,30 +30,33 @@ const toIndexedArray = (initialSet: any[]) => const activeProps = { visible: true, disabled: false }; const disabledProps = { visible: true, disabled: true }; const notVisibleProps = { visible: false, disabled: false }; - const visibleItem = { display: 'item', id: 'item', ...activeProps }; const notVisibleSection = { display: 'Not visible', id: 'not-visible', + order: 10, visibleItems: toIndexedArray([visibleItem]), ...notVisibleProps, }; const disabledSection = { display: 'Disabled', id: 'disabled', + order: 10, visibleItems: toIndexedArray([visibleItem]), ...disabledProps, }; const noItemsSection = { display: 'No items', id: 'no-items', + order: 10, visibleItems: toIndexedArray([]), ...activeProps, }; const noActiveItemsSection = { display: 'No active items', id: 'no-active-items', + order: 10, visibleItems: toIndexedArray([ { display: 'disabled', id: 'disabled', ...disabledProps }, { display: 'notVisible', id: 'notVisible', ...notVisibleProps }, @@ -63,6 +66,7 @@ const noActiveItemsSection = { const activeSection = { display: 'activeSection', id: 'activeSection', + order: 10, visibleItems: toIndexedArray([visibleItem]), ...activeProps, }; @@ -76,7 +80,19 @@ const managementSections = [ ]; describe('Management', () => { - it('filters and filters and maps section objects into SidebarNav items', () => { - expect(sideNavItems(managementSections, 'active-item-id')).toMatchSnapshot(); + it('maps legacy sections and apps into SidebarNav items', () => { + expect(mergeLegacyItems([], managementSections, 'active-item-id')).toMatchSnapshot(); + }); + + it('adds legacy apps to existing SidebarNav sections', () => { + const navSection = { + 'data-test-subj': 'activeSection', + icon: null, + id: 'activeSection', + items: [], + name: 'activeSection', + order: 10, + }; + expect(mergeLegacyItems([navSection], managementSections, 'active-item-id')).toMatchSnapshot(); }); }); diff --git a/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx new file mode 100644 index 0000000000000..cb0b82d0f0bde --- /dev/null +++ b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx @@ -0,0 +1,200 @@ +/* + * 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 { + EuiIcon, + // @ts-ignore + EuiSideNav, + EuiScreenReaderOnly, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { LegacySection, LegacyApp } from '../../types'; +import { ManagementApp } from '../../management_app'; +import { ManagementSection } from '../../management_section'; + +interface NavApp { + id: string; + name: string; + [key: string]: unknown; + order: number; // only needed while merging platform and legacy +} + +interface NavSection extends NavApp { + items: NavApp[]; +} + +interface ManagementSidebarNavProps { + getSections: () => ManagementSection[]; + legacySections: LegacySection[]; + selectedId: string; +} + +interface ManagementSidebarNavState { + isSideNavOpenOnMobile: boolean; +} + +const managementSectionOrAppToNav = (appOrSection: ManagementApp | ManagementSection) => ({ + id: appOrSection.id, + name: appOrSection.title, + 'data-test-subj': appOrSection.id, + order: appOrSection.order, +}); + +const managementSectionToNavSection = (section: ManagementSection) => { + const iconType = section.euiIconType + ? section.euiIconType + : section.icon + ? section.icon + : 'empty'; + + return { + icon: , + ...managementSectionOrAppToNav(section), + }; +}; + +const managementAppToNavItem = (selectedId?: string, parentId?: string) => ( + app: ManagementApp +) => ({ + isSelected: selectedId === app.id, + href: `#/management/${parentId}/${app.id}`, + ...managementSectionOrAppToNav(app), +}); + +const legacySectionToNavSection = (section: LegacySection) => ({ + name: section.display, + id: section.id, + icon: section.icon ? : null, + items: [], + 'data-test-subj': section.id, + // @ts-ignore + order: section.order, +}); + +const legacyAppToNavItem = (app: LegacyApp, selectedId: string) => ({ + isSelected: selectedId === app.id, + name: app.display, + id: app.id, + href: app.url, + 'data-test-subj': app.id, + // @ts-ignore + order: app.order, +}); + +const sectionVisible = (section: LegacySection | LegacyApp) => !section.disabled && section.visible; + +const sideNavItems = (sections: ManagementSection[], selectedId: string) => + sections.map(section => ({ + items: section.getAppsEnabled().map(managementAppToNavItem(selectedId, section.id)), + ...managementSectionToNavSection(section), + })); + +const findOrAddSection = (navItems: NavSection[], legacySection: LegacySection): NavSection => { + const foundSection = navItems.find(sec => sec.id === legacySection.id); + + if (foundSection) { + return foundSection; + } else { + const newSection = legacySectionToNavSection(legacySection); + navItems.push(newSection); + navItems.sort((a: NavSection, b: NavSection) => a.order - b.order); // only needed while merging platform and legacy + return newSection; + } +}; + +export const mergeLegacyItems = ( + navItems: NavSection[], + legacySections: LegacySection[], + selectedId: string +) => { + const filteredLegacySections = legacySections + .filter(sectionVisible) + .filter(section => section.visibleItems.length); + + filteredLegacySections.forEach(legacySection => { + const section = findOrAddSection(navItems, legacySection); + legacySection.visibleItems.forEach(app => { + section.items.push(legacyAppToNavItem(app, selectedId)); + return section.items.sort((a, b) => a.order - b.order); + }); + }); + + return navItems; +}; + +const sectionsToItems = ( + sections: ManagementSection[], + legacySections: LegacySection[], + selectedId: string +) => { + const navItems = sideNavItems(sections, selectedId); + return mergeLegacyItems(navItems, legacySections, selectedId); +}; + +export class ManagementSidebarNav extends React.Component< + ManagementSidebarNavProps, + ManagementSidebarNavState +> { + constructor(props: ManagementSidebarNavProps) { + super(props); + this.state = { + isSideNavOpenOnMobile: false, + }; + } + + public render() { + const HEADER_ID = 'management-nav-header'; + + return ( + <> + +

+ {i18n.translate('management.nav.label', { + defaultMessage: 'Management', + })} +

+
+ + + ); + } + + private renderMobileTitle() { + return ; + } + + private toggleOpenOnMobile = () => { + this.setState({ + isSideNavOpenOnMobile: !this.state.isSideNavOpenOnMobile, + }); + }; +} diff --git a/src/plugins/management/public/index.ts b/src/plugins/management/public/index.ts index ee3866c734f19..faec466dbd671 100644 --- a/src/plugins/management/public/index.ts +++ b/src/plugins/management/public/index.ts @@ -24,4 +24,7 @@ export function plugin(initializerContext: PluginInitializerContext) { return new ManagementPlugin(); } -export { ManagementStart } from './types'; +export { ManagementSetup, ManagementStart, RegisterManagementApp } from './types'; +export { ManagementApp } from './management_app'; +export { ManagementSection } from './management_section'; +export { ManagementSidebarNav } from './components'; // for use in legacy management apps diff --git a/src/plugins/management/public/legacy/index.js b/src/plugins/management/public/legacy/index.js index 63b9d2c6b27d7..f2e0ba89b7b59 100644 --- a/src/plugins/management/public/legacy/index.js +++ b/src/plugins/management/public/legacy/index.js @@ -17,4 +17,5 @@ * under the License. */ -export { management } from './sections_register'; +export { LegacyManagementAdapter } from './sections_register'; +export { LegacyManagementSection } from './section'; diff --git a/src/plugins/management/public/legacy/section.js b/src/plugins/management/public/legacy/section.js index f269e3fe295b7..7d733b7b3173b 100644 --- a/src/plugins/management/public/legacy/section.js +++ b/src/plugins/management/public/legacy/section.js @@ -22,7 +22,7 @@ import { IndexedArray } from '../../../../legacy/ui/public/indexed_array'; const listeners = []; -export class ManagementSection { +export class LegacyManagementSection { /** * @param {string} id * @param {object} options @@ -83,7 +83,11 @@ export class ManagementSection { */ register(id, options = {}) { - const item = new ManagementSection(id, assign(options, { parent: this }), this.capabilities); + const item = new LegacyManagementSection( + id, + assign(options, { parent: this }), + this.capabilities + ); if (this.hasItem(id)) { throw new Error(`'${id}' is already registered`); diff --git a/src/plugins/management/public/legacy/section.test.js b/src/plugins/management/public/legacy/section.test.js index 61bafd298afb3..45cc80ef80edd 100644 --- a/src/plugins/management/public/legacy/section.test.js +++ b/src/plugins/management/public/legacy/section.test.js @@ -17,7 +17,7 @@ * under the License. */ -import { ManagementSection } from './section'; +import { LegacyManagementSection } from './section'; import { IndexedArray } from '../../../../legacy/ui/public/indexed_array'; const capabilitiesMock = { @@ -29,42 +29,42 @@ const capabilitiesMock = { describe('ManagementSection', () => { describe('constructor', () => { it('defaults display to id', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.display).toBe('kibana'); }); it('defaults visible to true', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.visible).toBe(true); }); it('defaults disabled to false', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.disabled).toBe(false); }); it('defaults tooltip to empty string', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.tooltip).toBe(''); }); it('defaults url to empty string', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.url).toBe(''); }); it('exposes items', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.items).toHaveLength(0); }); it('exposes visibleItems', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.visibleItems).toHaveLength(0); }); it('assigns all options', () => { - const section = new ManagementSection( + const section = new LegacyManagementSection( 'kibana', { description: 'test', url: 'foobar' }, capabilitiesMock @@ -78,11 +78,11 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); }); it('returns a ManagementSection', () => { - expect(section.register('about')).toBeInstanceOf(ManagementSection); + expect(section.register('about')).toBeInstanceOf(LegacyManagementSection); }); it('provides a reference to the parent', () => { @@ -93,7 +93,7 @@ describe('ManagementSection', () => { section.register('about', { description: 'test' }); expect(section.items).toHaveLength(1); - expect(section.items[0]).toBeInstanceOf(ManagementSection); + expect(section.items[0]).toBeInstanceOf(LegacyManagementSection); expect(section.items[0].id).toBe('about'); }); @@ -126,7 +126,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('about'); }); @@ -157,12 +157,12 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('about'); }); it('returns registered section', () => { - expect(section.getSection('about')).toBeInstanceOf(ManagementSection); + expect(section.getSection('about')).toBeInstanceOf(LegacyManagementSection); }); it('returns undefined if un-registered', () => { @@ -171,7 +171,7 @@ describe('ManagementSection', () => { it('returns sub-sections specified via a /-separated path', () => { section.getSection('about').register('time'); - expect(section.getSection('about/time')).toBeInstanceOf(ManagementSection); + expect(section.getSection('about/time')).toBeInstanceOf(LegacyManagementSection); expect(section.getSection('about/time')).toBe(section.getSection('about').getSection('time')); }); @@ -184,7 +184,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('three', { order: 3 }); section.register('one', { order: 1 }); @@ -214,7 +214,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); }); it('hide sets visible to false', () => { @@ -233,7 +233,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); }); it('disable sets disabled to true', () => { @@ -251,7 +251,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('three', { order: 3 }); section.register('one', { order: 1 }); diff --git a/src/plugins/management/public/legacy/sections_register.js b/src/plugins/management/public/legacy/sections_register.js index 888b2c5bc3aeb..63d919377f89e 100644 --- a/src/plugins/management/public/legacy/sections_register.js +++ b/src/plugins/management/public/legacy/sections_register.js @@ -17,44 +17,48 @@ * under the License. */ -import { ManagementSection } from './section'; +import { LegacyManagementSection } from './section'; import { i18n } from '@kbn/i18n'; -export const management = capabilities => { - const main = new ManagementSection( - 'management', - { - display: i18n.translate('management.displayName', { - defaultMessage: 'Management', - }), - }, - capabilities - ); +export class LegacyManagementAdapter { + main = undefined; + init = capabilities => { + this.main = new LegacyManagementSection( + 'management', + { + display: i18n.translate('management.displayName', { + defaultMessage: 'Management', + }), + }, + capabilities + ); - main.register('data', { - display: i18n.translate('management.connectDataDisplayName', { - defaultMessage: 'Connect Data', - }), - order: 0, - }); + this.main.register('data', { + display: i18n.translate('management.connectDataDisplayName', { + defaultMessage: 'Connect Data', + }), + order: 0, + }); - main.register('elasticsearch', { - display: 'Elasticsearch', - order: 20, - icon: 'logoElasticsearch', - }); + this.main.register('elasticsearch', { + display: 'Elasticsearch', + order: 20, + icon: 'logoElasticsearch', + }); - main.register('kibana', { - display: 'Kibana', - order: 30, - icon: 'logoKibana', - }); + this.main.register('kibana', { + display: 'Kibana', + order: 30, + icon: 'logoKibana', + }); - main.register('logstash', { - display: 'Logstash', - order: 30, - icon: 'logoLogstash', - }); + this.main.register('logstash', { + display: 'Logstash', + order: 30, + icon: 'logoLogstash', + }); - return main; -}; + return this.main; + }; + getManagement = () => this.main; +} diff --git a/src/plugins/management/public/management_app.test.tsx b/src/plugins/management/public/management_app.test.tsx new file mode 100644 index 0000000000000..a76b234d95ef5 --- /dev/null +++ b/src/plugins/management/public/management_app.test.tsx @@ -0,0 +1,66 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { coreMock } from '../../../core/public/mocks'; + +import { ManagementApp } from './management_app'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; + +function createTestApp() { + const legacySection = new LegacyManagementSection('legacy'); + return new ManagementApp( + { + id: 'test-app', + title: 'Test App', + basePath: '', + mount(params) { + params.setBreadcrumbs([{ text: 'Test App' }]); + ReactDOM.render(
Test App - Hello world!
, params.element); + + return () => { + ReactDOM.unmountComponentAtNode(params.element); + }; + }, + }, + () => [], + jest.fn(), + () => legacySection, + coreMock.createSetup().getStartServices + ); +} + +test('Management app can mount and unmount', async () => { + const testApp = createTestApp(); + const container = document.createElement('div'); + document.body.appendChild(container); + const unmount = testApp.mount({ element: container, basePath: '', setBreadcrumbs: jest.fn() }); + expect(container).toMatchSnapshot(); + (await unmount)(); + expect(container).toMatchSnapshot(); +}); + +test('Enabled by default, can disable', () => { + const testApp = createTestApp(); + expect(testApp.enabled).toBe(true); + testApp.disable(); + expect(testApp.enabled).toBe(false); +}); diff --git a/src/plugins/management/public/management_app.tsx b/src/plugins/management/public/management_app.tsx new file mode 100644 index 0000000000000..f7e8dba4f8210 --- /dev/null +++ b/src/plugins/management/public/management_app.tsx @@ -0,0 +1,102 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import ReactDOM from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { CreateManagementApp, ManagementSectionMount, Unmount } from './types'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { ManagementChrome } from './components'; +import { ManagementSection } from './management_section'; +import { ChromeBreadcrumb, CoreSetup } from '../../../core/public/'; + +export class ManagementApp { + readonly id: string; + readonly title: string; + readonly basePath: string; + readonly order: number; + readonly mount: ManagementSectionMount; + protected enabledStatus: boolean = true; + + constructor( + { id, title, basePath, order = 100, mount }: CreateManagementApp, + getSections: () => ManagementSection[], + registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], + getLegacyManagementSections: () => LegacyManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + this.id = id; + this.title = title; + this.basePath = basePath; + this.order = order; + this.mount = mount; + + registerLegacyApp({ + id: basePath.substr(1), // get rid of initial slash + title, + mount: async ({}, params) => { + let appUnmount: Unmount; + async function setBreadcrumbs(crumbs: ChromeBreadcrumb[]) { + const [coreStart] = await getStartServices(); + coreStart.chrome.setBreadcrumbs([ + { + text: i18n.translate('management.breadcrumb', { + defaultMessage: 'Management', + }), + href: '#/management', + }, + ...crumbs, + ]); + } + + ReactDOM.render( + { + appUnmount = await mount({ + basePath, + element, + setBreadcrumbs, + }); + }} + />, + params.element + ); + + return async () => { + appUnmount(); + ReactDOM.unmountComponentAtNode(params.element); + }; + }, + }); + } + public enable() { + this.enabledStatus = true; + } + public disable() { + this.enabledStatus = false; + } + public get enabled() { + return this.enabledStatus; + } +} diff --git a/src/plugins/management/public/management_section.test.ts b/src/plugins/management/public/management_section.test.ts new file mode 100644 index 0000000000000..c68175ee0a678 --- /dev/null +++ b/src/plugins/management/public/management_section.test.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ManagementSection } from './management_section'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { coreMock } from '../../../core/public/mocks'; + +function createSection(registerLegacyApp: () => void) { + const legacySection = new LegacyManagementSection('legacy'); + const getLegacySection = () => legacySection; + const getManagementSections: () => ManagementSection[] = () => []; + + const testSectionConfig = { id: 'test-section', title: 'Test Section' }; + return new ManagementSection( + testSectionConfig, + getManagementSections, + registerLegacyApp, + getLegacySection, + coreMock.createSetup().getStartServices + ); +} + +test('cannot register two apps with the same id', () => { + const registerLegacyApp = jest.fn(); + const section = createSection(registerLegacyApp); + + const testAppConfig = { id: 'test-app', title: 'Test App', mount: () => () => {} }; + + section.registerApp(testAppConfig); + expect(registerLegacyApp).toHaveBeenCalled(); + expect(section.apps.length).toEqual(1); + + expect(() => { + section.registerApp(testAppConfig); + }).toThrow(); +}); + +test('can enable and disable apps', () => { + const registerLegacyApp = jest.fn(); + const section = createSection(registerLegacyApp); + + const testAppConfig = { id: 'test-app', title: 'Test App', mount: () => () => {} }; + + const app = section.registerApp(testAppConfig); + expect(section.getAppsEnabled().length).toEqual(1); + app.disable(); + expect(section.getAppsEnabled().length).toEqual(0); +}); diff --git a/src/plugins/management/public/management_section.ts b/src/plugins/management/public/management_section.ts new file mode 100644 index 0000000000000..2f323c4b6a9cf --- /dev/null +++ b/src/plugins/management/public/management_section.ts @@ -0,0 +1,78 @@ +/* + * 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 { CreateSection, RegisterManagementAppArgs } from './types'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; +import { CoreSetup } from '../../../core/public'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { ManagementApp } from './management_app'; + +export class ManagementSection { + public readonly id: string = ''; + public readonly title: string = ''; + public readonly apps: ManagementApp[] = []; + public readonly order: number; + public readonly euiIconType?: string; + public readonly icon?: string; + private readonly getSections: () => ManagementSection[]; + private readonly registerLegacyApp: KibanaLegacySetup['registerLegacyApp']; + private readonly getLegacyManagementSection: () => LegacyManagementSection; + private readonly getStartServices: CoreSetup['getStartServices']; + + constructor( + { id, title, order = 100, euiIconType, icon }: CreateSection, + getSections: () => ManagementSection[], + registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], + getLegacyManagementSection: () => ManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + this.id = id; + this.title = title; + this.order = order; + this.euiIconType = euiIconType; + this.icon = icon; + this.getSections = getSections; + this.registerLegacyApp = registerLegacyApp; + this.getLegacyManagementSection = getLegacyManagementSection; + this.getStartServices = getStartServices; + } + + registerApp({ id, title, order, mount }: RegisterManagementAppArgs) { + if (this.getApp(id)) { + throw new Error(`Management app already registered - id: ${id}, title: ${title}`); + } + + const app = new ManagementApp( + { id, title, order, mount, basePath: `/management/${this.id}/${id}` }, + this.getSections, + this.registerLegacyApp, + this.getLegacyManagementSection, + this.getStartServices + ); + this.apps.push(app); + return app; + } + getApp(id: ManagementApp['id']) { + return this.apps.find(app => app.id === id); + } + getAppsEnabled() { + return this.apps.filter(app => app.enabled).sort((a, b) => a.order - b.order); + } +} diff --git a/src/plugins/management/public/management_service.test.ts b/src/plugins/management/public/management_service.test.ts new file mode 100644 index 0000000000000..854406a10335b --- /dev/null +++ b/src/plugins/management/public/management_service.test.ts @@ -0,0 +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. + */ + +import { ManagementService } from './management_service'; +import { coreMock } from '../../../core/public/mocks'; + +const mockKibanaLegacy = { registerLegacyApp: () => {}, forwardApp: () => {} }; + +test('Provides default sections', () => { + const service = new ManagementService().setup( + mockKibanaLegacy, + () => {}, + coreMock.createSetup().getStartServices + ); + expect(service.getAllSections().length).toEqual(3); + expect(service.getSection('kibana')).not.toBeUndefined(); + expect(service.getSection('logstash')).not.toBeUndefined(); + expect(service.getSection('elasticsearch')).not.toBeUndefined(); +}); + +test('Register section, enable and disable', () => { + const service = new ManagementService().setup( + mockKibanaLegacy, + () => {}, + coreMock.createSetup().getStartServices + ); + const testSection = service.register({ id: 'test-section', title: 'Test Section' }); + expect(service.getSection('test-section')).not.toBeUndefined(); + + const testApp = testSection.registerApp({ + id: 'test-app', + title: 'Test App', + mount: () => () => {}, + }); + expect(testSection.getApp('test-app')).not.toBeUndefined(); + expect(service.getSectionsEnabled().length).toEqual(1); + testApp.disable(); + expect(service.getSectionsEnabled().length).toEqual(0); +}); diff --git a/src/plugins/management/public/management_service.ts b/src/plugins/management/public/management_service.ts new file mode 100644 index 0000000000000..4a900345b3843 --- /dev/null +++ b/src/plugins/management/public/management_service.ts @@ -0,0 +1,103 @@ +/* + * 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 { ManagementSection } from './management_section'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { CreateSection } from './types'; +import { CoreSetup, CoreStart } from '../../../core/public'; + +export class ManagementService { + private sections: ManagementSection[] = []; + + private register( + registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], + getLegacyManagement: () => LegacyManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + return (section: CreateSection) => { + if (this.getSection(section.id)) { + throw Error(`ManagementSection '${section.id}' already registered`); + } + + const newSection = new ManagementSection( + section, + this.getSectionsEnabled.bind(this), + registerLegacyApp, + getLegacyManagement, + getStartServices + ); + this.sections.push(newSection); + return newSection; + }; + } + private getSection(sectionId: ManagementSection['id']) { + return this.sections.find(section => section.id === sectionId); + } + + private getAllSections() { + return this.sections; + } + + private getSectionsEnabled() { + return this.sections + .filter(section => section.getAppsEnabled().length > 0) + .sort((a, b) => a.order - b.order); + } + + private sharedInterface = { + getSection: this.getSection.bind(this), + getSectionsEnabled: this.getSectionsEnabled.bind(this), + getAllSections: this.getAllSections.bind(this), + }; + + public setup( + kibanaLegacy: KibanaLegacySetup, + getLegacyManagement: () => LegacyManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + const register = this.register.bind(this)( + kibanaLegacy.registerLegacyApp, + getLegacyManagement, + getStartServices + ); + + register({ id: 'kibana', title: 'Kibana', order: 30, euiIconType: 'logoKibana' }); + register({ id: 'logstash', title: 'Logstash', order: 30, euiIconType: 'logoLogstash' }); + register({ + id: 'elasticsearch', + title: 'Elasticsearch', + order: 20, + euiIconType: 'logoElasticsearch', + }); + + return { + register, + ...this.sharedInterface, + }; + } + + public start(navigateToApp: CoreStart['application']['navigateToApp']) { + return { + navigateToApp, // apps are currently registered as top level apps but this may change in the future + ...this.sharedInterface, + }; + } +} diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index c65dfd1dc7bb4..195d96c11d8d9 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -18,18 +18,30 @@ */ import { CoreSetup, CoreStart, Plugin } from 'kibana/public'; -import { ManagementStart } from './types'; +import { ManagementSetup, ManagementStart } from './types'; +import { ManagementService } from './management_service'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; // @ts-ignore -import { management } from './legacy'; +import { LegacyManagementAdapter } from './legacy'; -export class ManagementPlugin implements Plugin<{}, ManagementStart> { - public setup(core: CoreSetup) { - return {}; +export class ManagementPlugin implements Plugin { + private managementSections = new ManagementService(); + private legacyManagement = new LegacyManagementAdapter(); + + public setup(core: CoreSetup, { kibana_legacy }: { kibana_legacy: KibanaLegacySetup }) { + return { + sections: this.managementSections.setup( + kibana_legacy, + this.legacyManagement.getManagement, + core.getStartServices + ), + }; } public start(core: CoreStart) { return { - legacy: management(core.application.capabilities), + sections: this.managementSections.start(core.application.navigateToApp), + legacy: this.legacyManagement.init(core.application.capabilities), }; } } diff --git a/src/plugins/management/public/types.ts b/src/plugins/management/public/types.ts index 6ca1faf338c39..4dbea30ff062d 100644 --- a/src/plugins/management/public/types.ts +++ b/src/plugins/management/public/types.ts @@ -17,6 +17,82 @@ * under the License. */ +import { IconType } from '@elastic/eui'; +import { ManagementApp } from './management_app'; +import { ManagementSection } from './management_section'; +import { ChromeBreadcrumb, ApplicationStart } from '../../../core/public/'; + +export interface ManagementSetup { + sections: SectionsServiceSetup; +} + export interface ManagementStart { + sections: SectionsServiceStart; legacy: any; } + +interface SectionsServiceSetup { + getSection: (sectionId: ManagementSection['id']) => ManagementSection | undefined; + getAllSections: () => ManagementSection[]; + register: RegisterSection; +} + +interface SectionsServiceStart { + getSection: (sectionId: ManagementSection['id']) => ManagementSection | undefined; + getAllSections: () => ManagementSection[]; + navigateToApp: ApplicationStart['navigateToApp']; +} + +export interface CreateSection { + id: string; + title: string; + order?: number; + euiIconType?: string; // takes precedence over `icon` property. + icon?: string; // URL to image file; fallback if no `euiIconType` +} + +export type RegisterSection = (section: CreateSection) => ManagementSection; + +export interface RegisterManagementAppArgs { + id: string; + title: string; + mount: ManagementSectionMount; + order?: number; +} + +export type RegisterManagementApp = (managementApp: RegisterManagementAppArgs) => ManagementApp; + +export type Unmount = () => Promise | void; + +interface ManagementAppMountParams { + basePath: string; // base path for setting up your router + element: HTMLElement; // element the section should render into + setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; +} + +export type ManagementSectionMount = ( + params: ManagementAppMountParams +) => Unmount | Promise; + +export interface CreateManagementApp { + id: string; + title: string; + basePath: string; + order?: number; + mount: ManagementSectionMount; +} + +export interface LegacySection extends LegacyApp { + visibleItems: LegacyApp[]; +} + +export interface LegacyApp { + disabled: boolean; + visible: boolean; + id: string; + display: string; + url?: string; + euiIconType?: IconType; + icon?: string; + order: number; +} diff --git a/test/plugin_functional/config.js b/test/plugin_functional/config.js index 87026ce25d9aa..e9a4f3bcc4b1a 100644 --- a/test/plugin_functional/config.js +++ b/test/plugin_functional/config.js @@ -37,6 +37,7 @@ export default async function({ readConfigFile }) { require.resolve('./test_suites/panel_actions'), require.resolve('./test_suites/embeddable_explorer'), require.resolve('./test_suites/core_plugins'), + require.resolve('./test_suites/management'), ], services: { ...functionalConfig.get('services'), diff --git a/test/plugin_functional/plugins/management_test_plugin/kibana.json b/test/plugin_functional/plugins/management_test_plugin/kibana.json new file mode 100644 index 0000000000000..e52b60b3a4e31 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "management_test_plugin", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["management_test_plugin"], + "server": false, + "ui": true, + "requiredPlugins": ["management"] +} diff --git a/test/plugin_functional/plugins/management_test_plugin/package.json b/test/plugin_functional/plugins/management_test_plugin/package.json new file mode 100644 index 0000000000000..656d92e9eb1f7 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/package.json @@ -0,0 +1,17 @@ +{ + "name": "management_test_plugin", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/management_test_plugin", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" + } +} diff --git a/test/plugin_functional/plugins/management_test_plugin/public/index.ts b/test/plugin_functional/plugins/management_test_plugin/public/index.ts new file mode 100644 index 0000000000000..1efcc6cd3bbd6 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/public/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { PluginInitializer } from 'kibana/public'; +import { + ManagementTestPlugin, + ManagementTestPluginSetup, + ManagementTestPluginStart, +} from './plugin'; + +export const plugin: PluginInitializer = () => + new ManagementTestPlugin(); diff --git a/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx b/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx new file mode 100644 index 0000000000000..8b7cdd653ed8c --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx @@ -0,0 +1,73 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import ReactDOM from 'react-dom'; +import { HashRouter as Router, Switch, Route, Link } from 'react-router-dom'; +import { CoreSetup, Plugin } from 'kibana/public'; +import { ManagementSetup } from '../../../../../src/plugins/management/public'; + +export class ManagementTestPlugin + implements Plugin { + public setup(core: CoreSetup, { management }: { management: ManagementSetup }) { + const testSection = management.sections.register({ + id: 'test-section', + title: 'Test Section', + euiIconType: 'logoKibana', + order: 25, + }); + + testSection!.registerApp({ + id: 'test-management', + title: 'Management Test', + mount(params) { + params.setBreadcrumbs([{ text: 'Management Test' }]); + ReactDOM.render( + +

Hello from management test plugin

+ + + + Link to /one + + + + + Link to basePath + + + +
, + params.element + ); + + return () => { + ReactDOM.unmountComponentAtNode(params.element); + }; + }, + }); + return {}; + } + + public start() {} + public stop() {} +} + +export type ManagementTestPluginSetup = ReturnType; +export type ManagementTestPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/management_test_plugin/tsconfig.json b/test/plugin_functional/plugins/management_test_plugin/tsconfig.json new file mode 100644 index 0000000000000..5fcaeafbb0d85 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*", + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/management/index.js b/test/plugin_functional/test_suites/management/index.js new file mode 100644 index 0000000000000..2bfc05547b292 --- /dev/null +++ b/test/plugin_functional/test_suites/management/index.js @@ -0,0 +1,24 @@ +/* + * 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 default function({ loadTestFile }) { + describe('management plugin', () => { + loadTestFile(require.resolve('./management_plugin')); + }); +} diff --git a/test/plugin_functional/test_suites/management/management_plugin.js b/test/plugin_functional/test_suites/management/management_plugin.js new file mode 100644 index 0000000000000..d65fb1dcd3a7e --- /dev/null +++ b/test/plugin_functional/test_suites/management/management_plugin.js @@ -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. + */ + +export default function({ getService, getPageObjects }) { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common']); + + describe('management plugin', function describeIndexTests() { + before(async () => { + await PageObjects.common.navigateToActualUrl('kibana', 'management'); + }); + + it('should be able to navigate to management test app', async () => { + await testSubjects.click('test-management'); + await testSubjects.existOrFail('test-management-header'); + }); + + it('should be able to navigate within management test app', async () => { + await testSubjects.click('test-management-link-one'); + await testSubjects.click('test-management-link-basepath'); + await testSubjects.existOrFail('test-management-link-one'); + }); + }); +} diff --git a/x-pack/legacy/plugins/infra/types/eui.d.ts b/x-pack/legacy/plugins/infra/types/eui.d.ts index afcb445a66adb..e73a73076923d 100644 --- a/x-pack/legacy/plugins/infra/types/eui.d.ts +++ b/x-pack/legacy/plugins/infra/types/eui.d.ts @@ -34,7 +34,7 @@ declare module '@elastic/eui' { items: Array<{ id: string; name: string; - onClick: () => void; + onClick?: () => void; }>; }>; mobileTitle?: React.ReactNode; diff --git a/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts b/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts index fa8ae64168673..fbd39db6969bd 100644 --- a/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts +++ b/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts @@ -6,6 +6,12 @@ import { ManagementService } from '.'; +const mockSections = { + getSection: jest.fn(), + getAllSections: jest.fn(), + navigateToApp: jest.fn(), +}; + describe('ManagementService', () => { describe('#start', () => { it('registers the spaces management page under the kibana section', () => { @@ -18,6 +24,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(mockKibanaSection), }, + sections: mockSections, }; const deps = { @@ -49,6 +56,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(mockKibanaSection), }, + sections: mockSections, }; const deps = { @@ -66,6 +74,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(undefined), }, + sections: mockSections, }; const deps = { @@ -94,6 +103,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(mockKibanaSection), }, + sections: mockSections, }; const deps = { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 09d71814e5bf0..545a4fc7c8f93 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -441,7 +441,9 @@ "common.ui.flotCharts.tueLabel": "火", "common.ui.flotCharts.wedLabel": "水", "common.ui.management.breadcrumb": "管理", - "common.ui.management.nav.menu": "管理メニュー", + "management.connectDataDisplayName": "データに接続", + "management.displayName": "管理", + "management.nav.menu": "管理メニュー", "common.ui.modals.cancelButtonLabel": "キャンセル", "common.ui.notify.fatalError.errorStatusMessage": "エラー {errStatus} {errStatusText}: {errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP リクエストが接続に失敗しました。Kibana サーバーが実行されていて、ご使用のブラウザの接続が正常に動作していることを確認するか、システム管理者にお問い合わせください。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e221cba874bcd..05251d20a66e4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -441,7 +441,9 @@ "common.ui.flotCharts.tueLabel": "周二", "common.ui.flotCharts.wedLabel": "周三", "common.ui.management.breadcrumb": "管理", - "common.ui.management.nav.menu": "管理菜单", + "management.connectDataDisplayName": "连接数据", + "management.displayName": "管理", + "management.nav.menu": "管理菜单", "common.ui.modals.cancelButtonLabel": "取消", "common.ui.notify.fatalError.errorStatusMessage": "错误 {errStatus} {errStatusText}:{errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP 请求无法连接。请检查 Kibana 服务器是否正在运行以及您的浏览器是否具有有效的连接,或请联系您的系统管理员。", From 404c42f9557ab774441389db955614d96923c3e0 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 8 Jan 2020 18:49:17 -0500 Subject: [PATCH 28/63] Filter scripted fields preview field list to source fields (#53826) Co-authored-by: Elastic Machine --- .../components/scripting_help/test_script.js | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js b/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js index 942f39fc98dec..12bf5c1cce004 100644 --- a/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js +++ b/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js @@ -30,6 +30,8 @@ import { EuiTitle, EuiCallOut, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { npStart } from 'ui/new_platform'; const { SearchBar } = npStart.plugins.data.ui; @@ -116,7 +118,13 @@ export class TestScript extends Component { if (previewData.error) { return ( - + -

First 10 results

+

+ +

{ - return !field.name.startsWith('_'); + const isMultiField = field.subType && field.subType.multi; + return !field.name.startsWith('_') && !isMultiField && !field.scripted; }) .forEach(field => { if (fieldsByTypeMap.has(field.type)) { @@ -180,9 +194,16 @@ export class TestScript extends Component { return ( - + - Run script + } /> @@ -219,11 +243,19 @@ export class TestScript extends Component { -

Preview results

+

+ +

- Run your script to preview the first 10 results. You can also select some additional - fields to include in your results to gain more context or add a query to filter on - specific documents. +

From 1e2cbb3710c46eb4ba1bfd29565027c3dd2420da Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 8 Jan 2020 19:32:10 -0500 Subject: [PATCH 29/63] [SIEM] Detection engine timeline (#53783) * change create to only have only one form to be open at the same time * add tick to risk score * remove compressed * fix select in schedule * fix bug to not allow more than one step panel to be open at a time * Add a color/health indicator to severity selector * Move and reword tags placeholder to bottom helper text * fix ux on the index patterns field * Reorganize MITRE ATT&CK threat * add url validation + some cleaning to prerp work for UT * add feature to get back timeline + be able to disable action on timeline modal * Add option to import the query from a saved timeline. * wip * Add timeline template selector * fix few bugs from last commit * review I * fix unit test for timeline_title * ui review * fix truncation on timeline selectable --- .../static/forms/components/field.tsx | 2 + .../static/forms/components/fields/index.ts | 1 + .../components/fields/super_select_field.tsx | 58 ++++ .../static/forms/hook_form_lib/constants.ts | 1 + .../components/open_timeline/helpers.ts | 6 +- .../public/components/open_timeline/index.tsx | 20 +- .../open_timeline/open_timeline.test.tsx | 16 +- .../open_timeline/open_timeline.tsx | 7 +- .../open_timeline_modal/index.tsx | 56 ++-- .../open_timeline_modal_body.test.tsx | 16 +- .../open_timeline_modal_body.tsx | 101 ++++--- .../timelines_table/actions_columns.test.tsx | 76 ++++- .../timelines_table/actions_columns.tsx | 20 +- .../timelines_table/common_columns.test.tsx | 81 +++-- .../timelines_table/common_columns.tsx | 2 - .../timelines_table/extended_columns.test.tsx | 9 +- .../icon_header_columns.test.tsx | 30 +- .../timelines_table/index.test.tsx | 70 +++-- .../open_timeline/timelines_table/index.tsx | 31 +- .../public/components/open_timeline/types.ts | 4 + .../timeline/search_super_select/index.tsx | 276 ++++++++++++++++++ .../search_super_select/translations.ts | 25 ++ .../detection_engine/rules/types.ts | 4 +- .../public/pages/detection_engine/index.tsx | 6 +- .../detection_engine/rules/all/actions.tsx | 2 +- .../detection_engine/rules/all/helpers.ts | 2 +- .../rules/components/add_item_form/index.tsx | 53 +++- .../components/description_step/helpers.tsx | 222 ++++++++++++++ .../components/description_step/index.tsx | 192 +++--------- .../components/description_step/types.ts | 33 +++ .../rules/components/mitre/helpers.ts | 18 ++ .../rules/components/mitre/index.tsx | 199 ++++++++----- .../rules/components/mitre/translations.ts | 6 +- .../rules/components/pick_timeline/index.tsx | 74 +++++ .../rules/components/query_bar/index.tsx | 126 +++++--- .../components/query_bar/translations.tsx | 14 + .../components/schedule_item_form/index.tsx | 30 +- .../rules/components/status_icon/index.tsx | 2 +- .../rules/components/step_about_rule/data.ts | 28 -- .../rules/components/step_about_rule/data.tsx | 43 +++ .../step_about_rule/default_value.ts | 20 +- .../components/step_about_rule/helpers.ts | 16 + .../components/step_about_rule/index.tsx | 62 ++-- .../components/step_about_rule/schema.tsx | 44 ++- .../step_about_rule/translations.ts | 7 + .../components/step_define_rule/index.tsx | 123 ++++---- .../components/step_define_rule/schema.tsx | 21 +- .../step_define_rule/translations.tsx | 22 ++ .../components/step_schedule_rule/index.tsx | 2 - .../detection_engine/rules/create/helpers.ts | 18 +- .../detection_engine/rules/create/index.tsx | 106 ++++--- .../rules/create/translations.ts | 4 + .../detection_engine/rules/details/index.tsx | 7 +- .../detection_engine/rules/edit/index.tsx | 7 +- .../pages/detection_engine/rules/helpers.tsx | 5 +- .../pages/detection_engine/rules/index.tsx | 12 +- .../pages/detection_engine/rules/types.ts | 5 +- .../routes/__mocks__/request_responses.ts | 2 + .../routes/index/signals_mapping.json | 3 + .../routes/rules/create_rules_bulk_route.ts | 2 + .../routes/rules/create_rules_route.ts | 2 + .../routes/rules/update_rules_bulk_route.ts | 2 + .../routes/rules/update_rules_route.ts | 2 + .../routes/rules/utils.test.ts | 11 + .../detection_engine/routes/rules/utils.ts | 1 + .../add_prepackaged_rules_schema.test.ts | 130 ++++++++- .../schemas/add_prepackaged_rules_schema.ts | 2 + .../schemas/create_rules_schema.test.ts | 120 +++++++- .../routes/schemas/create_rules_schema.ts | 2 + .../routes/schemas/schemas.ts | 5 + .../schemas/update_rules_schema.test.ts | 96 +++++- .../routes/schemas/update_rules_schema.ts | 2 + .../detection_engine/rules/create_rules.ts | 2 + .../rules/install_prepacked_rules.ts | 2 + .../detection_engine/rules/update_rules.ts | 2 + .../rules/queries/query_timelineid.json | 3 +- .../rules/queries/query_with_everything.json | 1 + .../saved_query_with_everything.json | 3 +- .../updates/update_query_everything.json | 1 + .../rules/updates/update_timelineid.json | 3 +- .../signals/__mocks__/es_results.ts | 1 + .../detection_engine/signals/build_rule.ts | 1 + .../signals/signal_rule_alert_type.ts | 1 + .../siem/server/lib/detection_engine/types.ts | 3 + 84 files changed, 2169 insertions(+), 679 deletions(-) create mode 100644 src/plugins/es_ui_shared/static/forms/components/fields/super_select_field.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/translations.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/helpers.ts diff --git a/src/plugins/es_ui_shared/static/forms/components/field.tsx b/src/plugins/es_ui_shared/static/forms/components/field.tsx index 89dea53d75b38..5b9a6dc9de002 100644 --- a/src/plugins/es_ui_shared/static/forms/components/field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/field.tsx @@ -37,6 +37,7 @@ import { RadioGroupField, RangeField, SelectField, + SuperSelectField, ToggleField, } from './fields'; @@ -50,6 +51,7 @@ const mapTypeToFieldComponent = { [FIELD_TYPES.RADIO_GROUP]: RadioGroupField, [FIELD_TYPES.RANGE]: RangeField, [FIELD_TYPES.SELECT]: SelectField, + [FIELD_TYPES.SUPER_SELECT]: SuperSelectField, [FIELD_TYPES.TOGGLE]: ToggleField, }; diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/index.ts b/src/plugins/es_ui_shared/static/forms/components/fields/index.ts index f973bb7b04d34..35635d0e8530c 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/index.ts +++ b/src/plugins/es_ui_shared/static/forms/components/fields/index.ts @@ -25,5 +25,6 @@ export * from './multi_select_field'; export * from './radio_group_field'; export * from './range_field'; export * from './select_field'; +export * from './super_select_field'; export * from './toggle_field'; export * from './text_area_field'; diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/super_select_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/super_select_field.tsx new file mode 100644 index 0000000000000..9b29d75230d7a --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/components/fields/super_select_field.tsx @@ -0,0 +1,58 @@ +/* + * 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 { EuiFormRow, EuiSuperSelect } from '@elastic/eui'; + +import { FieldHook, getFieldValidityAndErrorMessage } from '../../hook_form_lib'; + +interface Props { + field: FieldHook; + euiFieldProps?: Record; + idAria?: string; + [key: string]: any; +} + +export const SuperSelectField = ({ field, euiFieldProps = {}, ...rest }: Props) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + return ( + + { + field.setValue(value); + }} + options={[]} + isInvalid={isInvalid} + data-test-subj="select" + {...euiFieldProps} + /> + + ); +}; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts index df2807e59ab46..4056947483107 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts @@ -28,6 +28,7 @@ export const FIELD_TYPES = { RADIO_GROUP: 'radioGroup', RANGE: 'range', SELECT: 'select', + SUPER_SELECT: 'superSelect', MULTI_SELECT: 'multiSelect', }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts index 91480f20d8b00..41e13408c1e01 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts @@ -181,6 +181,7 @@ export interface QueryTimelineById { apolloClient: ApolloClient | ApolloClient<{}> | undefined; duplicate: boolean; timelineId: string; + onOpenTimeline?: (timeline: TimelineModel) => void; openTimeline?: boolean; updateIsLoading: ActionCreator<{ id: string; isLoading: boolean }>; updateTimeline: DispatchUpdateTimeline; @@ -190,6 +191,7 @@ export const queryTimelineById = ({ apolloClient, duplicate = false, timelineId, + onOpenTimeline, openTimeline = true, updateIsLoading, updateTimeline, @@ -209,7 +211,9 @@ export const queryTimelineById = ({ ); const { timeline, notes } = formatTimelineResultToModel(timelineToOpen, duplicate); - if (updateTimeline) { + if (onOpenTimeline != null) { + onOpenTimeline(timeline); + } else if (updateTimeline) { updateTimeline({ duplicate, from: getOr(getDefaultFromValue(), 'dateRange.start', timeline), diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index c22c5fdbcfbc5..a97cfefaf0393 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -12,18 +12,20 @@ import { Dispatch } from 'redux'; import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers'; import { deleteTimelineMutation } from '../../containers/timeline/delete/persist.gql_query'; import { AllTimelinesVariables, AllTimelinesQuery } from '../../containers/timeline/all'; - import { allTimelinesQuery } from '../../containers/timeline/all/index.gql_query'; import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../graphql/types'; import { State, timelineSelectors } from '../../store'; +import { timelineDefaults, TimelineModel } from '../../store/timeline/model'; import { createTimeline as dispatchCreateNewTimeline, updateIsLoading as dispatchUpdateIsLoading, } from '../../store/timeline/actions'; +import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { OpenTimeline } from './open_timeline'; import { OPEN_TIMELINE_CLASS_NAME, queryTimelineById, dispatchUpdateTimeline } from './helpers'; import { OpenTimelineModalBody } from './open_timeline_modal/open_timeline_modal_body'; import { + ActionTimelineToShow, DeleteTimelines, EuiSearchBarQuery, OnDeleteSelected, @@ -41,14 +43,14 @@ import { OpenTimelineReduxProps, } from './types'; import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants'; -import { ColumnHeader } from '../timeline/body/column_headers/column_header'; -import { timelineDefaults } from '../../store/timeline/model'; interface OwnProps { apolloClient: ApolloClient; /** Displays open timeline in modal */ isModal: boolean; closeModalTimeline?: () => void; + hideActions?: ActionTimelineToShow[]; + onOpenTimeline?: (timeline: TimelineModel) => void; } export type OpenTimelineOwnProps = OwnProps & @@ -69,15 +71,17 @@ export const getSelectedTimelineIds = (selectedItems: OpenTimelineResult[]): str /** Manages the state (e.g table selection) of the (pure) `OpenTimeline` component */ export const StatefulOpenTimelineComponent = React.memo( ({ + apolloClient, + closeModalTimeline, + createNewTimeline, defaultPageSize, + hideActions = [], isModal = false, + onOpenTimeline, + timeline, title, - apolloClient, - closeModalTimeline, updateTimeline, updateIsLoading, - timeline, - createNewTimeline, }) => { /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ const [itemIdToExpandedNotesRowMap, setItemIdToExpandedNotesRowMap] = useState< @@ -212,6 +216,7 @@ export const StatefulOpenTimelineComponent = React.memo( queryTimelineById({ apolloClient, duplicate, + onOpenTimeline, timelineId, updateIsLoading, updateTimeline, @@ -286,6 +291,7 @@ export const StatefulOpenTimelineComponent = React.memo( data-test-subj={'open-timeline-modal'} deleteTimelines={onDeleteOneTimeline} defaultPageSize={defaultPageSize} + hideActions={hideActions} isLoading={loading} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} onAddTimelinesToFavorites={undefined} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx index 1ed08eee633ab..a1ca7812bba34 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx @@ -143,7 +143,7 @@ describe('OpenTimeline', () => { ).toBe(true); }); - test('it shows extended columns and actions when onDeleteSelected and deleteTimelines are specified', () => { + test('it shows the delete action columns when onDeleteSelected and deleteTimelines are specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(true); + expect(props.actionTimelineToShow).toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected undefined and deleteTimelines is specified', () => { + test('it does NOT show the delete action columns when is onDeleteSelected undefined and deleteTimelines is specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected provided and deleteTimelines is undefined', () => { + test('it does NOT show the delete action columns when is onDeleteSelected provided and deleteTimelines is undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when both onDeleteSelected and deleteTimelines are undefined', () => { + test('it does NOT show the delete action when both onDeleteSelected and deleteTimelines are undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 59ccfc8b250aa..8aab02b495392 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -57,6 +57,11 @@ export const OpenTimeline = React.memo( /> ( pageIndex={pageIndex} pageSize={pageSize} searchResults={searchResults} - showExtendedColumnsAndActions={onDeleteSelected != null && deleteTimelines != null} + showExtendedColumns={true} sortDirection={sortDirection} sortField={sortField} totalSearchResultsCount={totalSearchResultsCount} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx index cd89eb8aad6f4..c530929a3c96e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx @@ -7,39 +7,49 @@ import { EuiModal, EuiOverlayMask } from '@elastic/eui'; import React from 'react'; +import { TimelineModel } from '../../../store/timeline/model'; import { useApolloClient } from '../../../utils/apollo_context'; + import * as i18n from '../translations'; +import { ActionTimelineToShow } from '../types'; import { StatefulOpenTimeline } from '..'; export interface OpenTimelineModalProps { onClose: () => void; + hideActions?: ActionTimelineToShow[]; + modalTitle?: string; + onOpen?: (timeline: TimelineModel) => void; } const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; const OPEN_TIMELINE_MODAL_WIDTH = 1000; // px -export const OpenTimelineModal = React.memo(({ onClose }) => { - const apolloClient = useApolloClient(); - - if (!apolloClient) return null; - - return ( - - - - - - ); -}); +export const OpenTimelineModal = React.memo( + ({ hideActions = [], modalTitle, onClose, onOpen }) => { + const apolloClient = useApolloClient(); + + if (!apolloClient) return null; + + return ( + + + + + + ); + } +); OpenTimelineModal.displayName = 'OpenTimelineModal'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx index 1010504c0acac..2c3adb138b7ac 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx @@ -143,7 +143,7 @@ describe('OpenTimelineModal', () => { ).toBe(true); }); - test('it shows extended columns and actions when onDeleteSelected and deleteTimelines are specified', () => { + test('it shows the delete action when onDeleteSelected and deleteTimelines are specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(true); + expect(props.actionTimelineToShow).toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected undefined and deleteTimelines is specified', () => { + test('it does NOT show the delete when is onDeleteSelected undefined and deleteTimelines is specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected provided and deleteTimelines is undefined', () => { + test('it does NOT show the delete action when is onDeleteSelected provided and deleteTimelines is undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when both onDeleteSelected and deleteTimelines are undefined', () => { + test('it does NOT show extended columns when both onDeleteSelected and deleteTimelines are undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index e28725973aff2..dcd0b37770583 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -5,10 +5,10 @@ */ import { EuiModalBody, EuiModalHeader } from '@elastic/eui'; -import React from 'react'; +import React, { memo, useMemo } from 'react'; import styled from 'styled-components'; -import { OpenTimelineProps } from '../types'; +import { OpenTimelineProps, ActionTimelineToShow } from '../types'; import { SearchRow } from '../search_row'; import { TimelinesTable } from '../timelines_table'; import { TitleRow } from '../title_row'; @@ -19,10 +19,11 @@ export const HeaderContainer = styled.div` HeaderContainer.displayName = 'HeaderContainer'; -export const OpenTimelineModalBody = React.memo( +export const OpenTimelineModalBody = memo( ({ deleteTimelines, defaultPageSize, + hideActions = [], isLoading, itemIdToExpandedNotesRowMap, onAddTimelinesToFavorites, @@ -43,51 +44,61 @@ export const OpenTimelineModalBody = React.memo( sortField, title, totalSearchResultsCount, - }) => ( - <> - - - + }) => { + const actionsToShow = useMemo(() => { + const actions: ActionTimelineToShow[] = + onDeleteSelected != null && deleteTimelines != null + ? ['delete', 'duplicate'] + : ['duplicate']; + return actions.filter(action => !hideActions.includes(action)); + }, [onDeleteSelected, deleteTimelines, hideActions]); + return ( + <> + + + + + + + - + - - - - - - - - ) + + + ); + } ); OpenTimelineModalBody.displayName = 'OpenTimelineModalBody'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx index 89d6b4befa787..eec11f571328f 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx @@ -27,10 +27,11 @@ describe('#getActionsColumns', () => { mockResults = cloneDeep(mockTimelineResults); }); - test('it renders the delete timeline (trash icon) when showDeleteAction is true (because showExtendedColumnsAndActions is true)', () => { + test('it renders the delete timeline (trash icon) when actionTimelineToShow is including the action delete', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -53,10 +54,11 @@ describe('#getActionsColumns', () => { expect(wrapper.find('[data-test-subj="delete-timeline"]').exists()).toBe(true); }); - test('it does NOT render the delete timeline (trash icon) when showDeleteAction is false (because showExtendedColumnsAndActions is false)', () => { + test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow is NOT including the action delete', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -79,10 +81,65 @@ describe('#getActionsColumns', () => { expect(wrapper.find('[data-test-subj="delete-timeline"]').exists()).toBe(false); }); + test('it renders the duplicate icon timeline when actionTimelineToShow is including the action duplicate', () => { + const wrapper = mountWithIntl( + + + + ); + + expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBe(true); + }); + + test('it does NOT render the duplicate timeline when actionTimelineToShow is NOT including the action duplicate)', () => { + const wrapper = mountWithIntl( + + + + ); + + expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBe(false); + }); + test('it does NOT render the delete timeline (trash icon) when deleteTimelines is not provided', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -111,6 +168,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -141,6 +199,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -174,6 +233,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx index 51dba21ac225c..2b8bd3339cca2 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx @@ -12,19 +12,24 @@ import React from 'react'; import { ACTION_COLUMN_WIDTH } from './common_styles'; import { DeleteTimelineModalButton } from '../delete_timeline_modal'; import * as i18n from '../translations'; -import { DeleteTimelines, OnOpenTimeline, OpenTimelineResult } from '../types'; +import { + ActionTimelineToShow, + DeleteTimelines, + OnOpenTimeline, + OpenTimelineResult, +} from '../types'; /** * Returns the action columns (e.g. delete, open duplicate timeline) */ export const getActionsColumns = ({ + actionTimelineToShow, onOpenTimeline, deleteTimelines, - showDeleteAction, }: { + actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; onOpenTimeline: OnOpenTimeline; - showDeleteAction: boolean; }) => { const openAsDuplicateColumn = { align: 'center', @@ -67,7 +72,10 @@ export const getActionsColumns = ({ width: ACTION_COLUMN_WIDTH, }; - return showDeleteAction && deleteTimelines != null - ? [openAsDuplicateColumn, deleteTimelineColumn] - : [openAsDuplicateColumn]; + return [ + actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, + actionTimelineToShow.includes('delete') && deleteTimelines != null + ? deleteTimelineColumn + : null, + ].filter(action => action != null); }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx index 559ee4a7bb494..0f2cda9d79f0b 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx @@ -37,6 +37,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -63,6 +64,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingNotes.length} @@ -89,6 +91,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={nullNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={nullNotes.length} @@ -115,6 +118,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptylNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptylNotes.length} @@ -143,6 +147,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -169,6 +174,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={nullSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={nullSavedObjectId.length} @@ -195,6 +201,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -231,6 +238,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -269,6 +277,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -311,6 +320,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -346,6 +356,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -377,6 +388,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -411,6 +423,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -442,6 +455,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingTitle} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingTitle.length} @@ -475,6 +489,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={withMissingSavedObjectIdAndTitle} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={withMissingSavedObjectIdAndTitle.length} @@ -508,6 +523,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={withJustWhitespaceTitle} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={withJustWhitespaceTitle.length} @@ -541,6 +557,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={withMissingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={withMissingSavedObjectId.length} @@ -571,6 +588,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -605,6 +623,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -637,6 +656,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -673,6 +693,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -704,6 +725,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -737,6 +759,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingDescription} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingDescription.length} @@ -771,6 +794,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={justWhitespaceDescription} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={justWhitespaceDescription.length} @@ -803,6 +827,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -834,6 +859,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -868,6 +894,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingUpdated} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingUpdated.length} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx index 0b51bd78283c5..0d3a73a389050 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx @@ -27,11 +27,9 @@ export const getCommonColumns = ({ itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, - showExtendedColumnsAndActions, }: { onOpenTimeline: OnOpenTimeline; onToggleShowNotes: OnToggleShowNotes; - showExtendedColumnsAndActions: boolean; itemIdToExpandedNotesRowMap: Record; }) => [ { diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx index bc88603721e8a..4cbe1e45c473b 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx @@ -35,6 +35,7 @@ describe('#getExtendedColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -66,6 +67,7 @@ describe('#getExtendedColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -99,6 +101,7 @@ describe('#getExtendedColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingUpdatedBy} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingUpdatedBy.length} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx index 26836787efab1..31377d176acac 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx @@ -30,6 +30,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -57,6 +58,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={with6Events} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={with6Events.length} @@ -82,6 +84,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -109,6 +112,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={with4Notes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={with4Notes.length} @@ -134,6 +138,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -161,6 +166,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={undefinedFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={undefinedFavorite.length} @@ -187,6 +193,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={nullFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={nullFavorite.length} @@ -213,6 +220,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptyFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptyFavorite.length} @@ -249,6 +257,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptyFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptyFavorite.length} @@ -289,6 +298,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptyFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptyFavorite.length} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx index 7d947cb28e9ec..26d9607a91fcd 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx @@ -28,10 +28,11 @@ describe('TimelinesTable', () => { mockResults = cloneDeep(mockTimelineResults); }); - test('it renders the select all timelines header checkbox when showExtendedColumnsAndActions is true', () => { + test('it renders the select all timelines header checkbox when actionTimelineToShow has the action selectable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -59,10 +60,11 @@ describe('TimelinesTable', () => { ).toBe(true); }); - test('it does NOT render the select all timelines header checkbox when showExtendedColumnsAndActions is false', () => { + test('it does NOT render the select all timelines header checkbox when actionTimelineToShow has not the action selectable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -90,10 +92,11 @@ describe('TimelinesTable', () => { ).toBe(false); }); - test('it renders the Modified By column when showExtendedColumnsAndActions is true ', () => { + test('it renders the Modified By column when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -121,10 +124,11 @@ describe('TimelinesTable', () => { ).toContain(i18n.MODIFIED_BY); }); - test('it renders the notes column in the position of the Modified By column when showExtendedColumnsAndActions is false', () => { + test('it renders the notes column in the position of the Modified By column when showExtendedColumns is false', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -148,16 +152,17 @@ describe('TimelinesTable', () => { wrapper .find('thead tr th') .at(5) - .find('[data-test-subj="notes-count-header-icon"]') + .find('svg[data-test-subj="notes-count-header-icon"]') .first() .exists() ).toBe(true); }); - test('it renders the delete timeline (trash icon) when showExtendedColumnsAndActions is true', () => { + test('it renders the delete timeline (trash icon) when actionTimelineToShow has the delete action', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -185,10 +190,11 @@ describe('TimelinesTable', () => { ).toBe(true); }); - test('it does NOT render the delete timeline (trash icon) when showExtendedColumnsAndActions is false', () => { + test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow has NOT the delete action', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -216,10 +222,11 @@ describe('TimelinesTable', () => { ).toBe(false); }); - test('it renders the rows per page selector when showExtendedColumnsAndActions is true', () => { + test('it renders the rows per page selector when showExtendedColumns is true', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -247,10 +254,11 @@ describe('TimelinesTable', () => { ).toBe(true); }); - test('it does NOT render the rows per page selector when showExtendedColumnsAndActions is false', () => { + test('it does NOT render the rows per page selector when showExtendedColumns is false', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -284,6 +292,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={defaultPageSize} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -311,10 +320,11 @@ describe('TimelinesTable', () => { ).toEqual('Rows per page: 123'); }); - test('it sorts the Last Modified column in descending order when showExtendedColumnsAndActions is true ', () => { + test('it sorts the Last Modified column in descending order when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -342,10 +352,11 @@ describe('TimelinesTable', () => { ).toContain(i18n.LAST_MODIFIED); }); - test('it sorts the Last Modified column in descending order when showExtendedColumnsAndActions is false ', () => { + test('it sorts the Last Modified column in descending order when showExtendedColumns is false ', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -376,6 +387,7 @@ describe('TimelinesTable', () => { test('it displays the expected message when no search results are found', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={[]} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={0} @@ -408,6 +420,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -446,6 +459,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -479,6 +493,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -510,6 +525,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx index ce88ade01d2ef..f09a9f6af048b 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx @@ -10,6 +10,7 @@ import styled from 'styled-components'; import * as i18n from '../translations'; import { + ActionTimelineToShow, DeleteTimelines, OnOpenTimeline, OnSelectionChange, @@ -36,8 +37,8 @@ const BasicTable = styled(EuiBasicTable)` `; BasicTable.displayName = 'BasicTable'; -const getExtendedColumnsIfEnabled = (showExtendedColumnsAndActions: boolean) => - showExtendedColumnsAndActions ? [...getExtendedColumns()] : []; +const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) => + showExtendedColumns ? [...getExtendedColumns()] : []; /** * Returns the column definitions (passed as the `columns` prop to @@ -46,34 +47,36 @@ const getExtendedColumnsIfEnabled = (showExtendedColumnsAndActions: boolean) => * `Timelines` page */ const getTimelinesTableColumns = ({ + actionTimelineToShow, deleteTimelines, itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, - showExtendedColumnsAndActions, + showExtendedColumns, }: { + actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; itemIdToExpandedNotesRowMap: Record; onOpenTimeline: OnOpenTimeline; onToggleShowNotes: OnToggleShowNotes; - showExtendedColumnsAndActions: boolean; + showExtendedColumns: boolean; }) => [ ...getCommonColumns({ itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, - showExtendedColumnsAndActions, }), - ...getExtendedColumnsIfEnabled(showExtendedColumnsAndActions), + ...getExtendedColumnsIfEnabled(showExtendedColumns), ...getIconHeaderColumns(), ...getActionsColumns({ deleteTimelines, onOpenTimeline, - showDeleteAction: showExtendedColumnsAndActions, + actionTimelineToShow, }), ]; export interface TimelinesTableProps { + actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; defaultPageSize: number; loading: boolean; @@ -85,7 +88,7 @@ export interface TimelinesTableProps { pageIndex: number; pageSize: number; searchResults: OpenTimelineResult[]; - showExtendedColumnsAndActions: boolean; + showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; totalSearchResultsCount: number; @@ -97,6 +100,7 @@ export interface TimelinesTableProps { */ export const TimelinesTable = React.memo( ({ + actionTimelineToShow, deleteTimelines, defaultPageSize, loading: isLoading, @@ -108,13 +112,13 @@ export const TimelinesTable = React.memo( pageIndex, pageSize, searchResults, - showExtendedColumnsAndActions, + showExtendedColumns, sortField, sortDirection, totalSearchResultsCount, }) => { const pagination = { - hidePerPageOptions: !showExtendedColumnsAndActions, + hidePerPageOptions: !showExtendedColumns, pageIndex, pageSize, pageSizeOptions: [ @@ -142,16 +146,17 @@ export const TimelinesTable = React.memo( return ( ( noItemsMessage={i18n.ZERO_TIMELINES_MATCH} onChange={onTableChange} pagination={pagination} - selection={showExtendedColumnsAndActions ? selection : undefined} + selection={actionTimelineToShow.includes('selectable') ? selection : undefined} sorting={sorting} /> ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index 7bbefb9efa99e..e5e85ccf0954a 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -95,6 +95,8 @@ export interface OnTableChangeParams { /** Invoked by the EUI table implementation when the user interacts with the table */ export type OnTableChange = (tableChange: OnTableChangeParams) => void; +export type ActionTimelineToShow = 'duplicate' | 'delete' | 'selectable'; + export interface OpenTimelineProps { /** Invoked when the user clicks the delete (trash) icon on an individual timeline */ deleteTimelines?: DeleteTimelines; @@ -140,6 +142,8 @@ export interface OpenTimelineProps { title: string; /** The total (server-side) count of the search results */ totalSearchResultsCount: number; + /** Hide action on timeline if needed it */ + hideActions?: ActionTimelineToShow[]; } export interface UpdateTimeline { diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx new file mode 100644 index 0000000000000..ac47b352a6276 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/index.tsx @@ -0,0 +1,276 @@ +/* + * 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 { + EuiHighlight, + EuiInputPopover, + EuiSuperSelect, + EuiSelectable, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiTextColor, + EuiFilterButton, + EuiFilterGroup, + EuiSpacer, +} from '@elastic/eui'; +import { Option } from '@elastic/eui/src/components/selectable/types'; +import { isEmpty } from 'lodash/fp'; +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { ListProps } from 'react-virtualized'; +import styled, { createGlobalStyle } from 'styled-components'; + +import { AllTimelinesQuery } from '../../../containers/timeline/all'; +import { getEmptyTagValue } from '../../empty_value'; +import { isUntitled } from '../../../components/open_timeline/helpers'; +import * as i18nTimeline from '../../../components/open_timeline/translations'; +import { SortFieldTimeline, Direction } from '../../../graphql/types'; +import * as i18n from './translations'; + +const SearchTimelineSuperSelectGlobalStyle = createGlobalStyle` + .euiPopover__panel.euiPopover__panel-isOpen.timeline-search-super-select-popover__popoverPanel { + visibility: hidden; + z-index: 0; + } +`; + +const MyEuiHighlight = styled(EuiHighlight)<{ selected: boolean }>` + padding-left: ${({ selected }) => (selected ? '3px' : '0px')}; +`; + +const MyEuiTextColor = styled(EuiTextColor)<{ selected: boolean }>` + padding-left: ${({ selected }) => (selected ? '20px' : '0px')}; +`; + +interface SearchTimelineSuperSelectProps { + isDisabled: boolean; + timelineId: string | null; + timelineTitle: string | null; + onTimelineChange: (timelineTitle: string, timelineId: string | null) => void; +} + +const basicSuperSelectOptions = [ + { + value: '-1', + inputDisplay: i18n.DEFAULT_TIMELINE_TITLE, + }, +]; + +const getBasicSelectableOptions = (timelineId: string) => [ + { + description: i18n.DEFAULT_TIMELINE_DESCRIPTION, + label: i18n.DEFAULT_TIMELINE_TITLE, + id: null, + title: i18n.DEFAULT_TIMELINE_TITLE, + checked: timelineId === '-1' ? 'on' : undefined, + } as Option, +]; + +const ORIGINAL_PAGE_SIZE = 50; +const POPOVER_HEIGHT = 260; +const TIMELINE_ITEM_HEIGHT = 50; +const SearchTimelineSuperSelectComponent: React.FC = ({ + isDisabled, + timelineId, + timelineTitle, + onTimelineChange, +}) => { + const [pageSize, setPageSize] = useState(ORIGINAL_PAGE_SIZE); + const [heightTrigger, setHeightTrigger] = useState(0); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [searchTimelineValue, setSearchTimelineValue] = useState(''); + const [onlyFavorites, setOnlyFavorites] = useState(false); + + const onSearchTimeline = useCallback(val => { + setSearchTimelineValue(val); + }, []); + + const handleClosePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const handleOpenPopover = useCallback(() => { + setIsPopoverOpen(true); + }, []); + + const handleOnToggleOnlyFavorites = useCallback(() => { + setOnlyFavorites(!onlyFavorites); + }, [onlyFavorites]); + + const renderTimelineOption = useCallback((option, searchValue) => { + return ( + <> + {option.checked === 'on' && } + + {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title} + +
+ + + {option.description != null && option.description.trim().length > 0 + ? option.description + : getEmptyTagValue()} + + + + ); + }, []); + + const handleTimelineChange = useCallback(options => { + const selectedTimeline = options.filter( + (option: { checked: string }) => option.checked === 'on' + ); + if (selectedTimeline != null && selectedTimeline.length > 0 && onTimelineChange != null) { + onTimelineChange( + isEmpty(selectedTimeline[0].title) + ? i18nTimeline.UNTITLED_TIMELINE + : selectedTimeline[0].title, + selectedTimeline[0].id + ); + } + setIsPopoverOpen(false); + }, []); + + const handleOnScroll = useCallback( + ( + totalTimelines: number, + totalCount: number, + { + clientHeight, + scrollHeight, + scrollTop, + }: { + clientHeight: number; + scrollHeight: number; + scrollTop: number; + } + ) => { + if (totalTimelines < totalCount) { + const clientHeightTrigger = clientHeight * 1.2; + if ( + scrollTop > 10 && + scrollHeight - scrollTop < clientHeightTrigger && + scrollHeight > heightTrigger + ) { + setHeightTrigger(scrollHeight); + setPageSize(pageSize + ORIGINAL_PAGE_SIZE); + } + } + }, + [heightTrigger, pageSize] + ); + + const superSelect = useMemo( + () => ( + + ), + [handleOpenPopover, isDisabled, timelineId, timelineTitle] + ); + + return ( + + + {({ timelines, loading, totalCount }) => ( + <> + + + + + {i18nTimeline.ONLY_FAVORITES} + + + + + + + ({ + description: t.description, + label: t.title, + id: t.savedObjectId, + key: `${t.title}-${index}`, + title: t.title, + checked: t.savedObjectId === timelineId ? 'on' : undefined, + } as Option) + ), + ]} + > + {(list, search) => ( + <> + {search} + {list} + + )} + + + )} + + + + ); +}; + +export const SearchTimelineSuperSelect = memo(SearchTimelineSuperSelectComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/translations.ts b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/translations.ts new file mode 100644 index 0000000000000..bffee407bc999 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const DEFAULT_TIMELINE_TITLE = i18n.translate('xpack.siem.timeline.defaultTimelineTitle', { + defaultMessage: 'Default blank timeline', +}); + +export const DEFAULT_TIMELINE_DESCRIPTION = i18n.translate( + 'xpack.siem.timeline.defaultTimelineDescription', + { + defaultMessage: 'Timeline offered by default when creating new timeline.', + } +); + +export const SEARCH_BOX_TIMELINE_PLACEHOLDER = i18n.translate( + 'xpack.siem.timeline.searchBoxPlaceholder', + { + defaultMessage: 'e.g. timeline name or description', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index 9f3cba7189fb1..655299c4a2a34 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -70,8 +70,8 @@ export const RuleSchema = t.intersection([ risk_score: t.number, rule_id: t.string, severity: t.string, - type: t.string, tags: t.array(t.string), + type: t.string, to: t.string, threats: t.array(t.unknown), updated_at: t.string, @@ -79,6 +79,8 @@ export const RuleSchema = t.intersection([ }), t.partial({ saved_id: t.string, + timeline_id: t.string, + timeline_title: t.string, }), ]); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx index e8a2c98a94a56..9c95c74cd62a5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx @@ -56,13 +56,13 @@ export const DetectionEngineContainer = React.memo(() => { - + - + - + diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx index f176109b1d7a5..469745262d944 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx @@ -17,7 +17,7 @@ import { import { Action } from './reducer'; export const editRuleAction = (rule: Rule, history: H.History) => { - history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/${rule.id}/edit`); + history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${rule.id}/edit`); }; export const runRuleAction = () => {}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts index 1909b75a85835..f5d3955314242 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts @@ -13,7 +13,7 @@ export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] id: rule.id, rule_id: rule.rule_id, rule: { - href: `#/detection-engine/rules/${encodeURIComponent(rule.id)}`, + href: `#/detection-engine/rules/id/${encodeURIComponent(rule.id)}`, name: rule.name, status: 'Status Placeholder', }, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index f090f6d97eaf9..725c7eeeedcfe 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx @@ -4,9 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonEmpty, EuiButtonIcon, EuiFormRow, EuiFieldText, EuiSpacer } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiSpacer, +} from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { ChangeEvent, useCallback, useEffect, useState, useRef } from 'react'; +import styled from 'styled-components'; import * as RuleI18n from '../../translations'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; @@ -17,11 +26,26 @@ interface AddItemProps { dataTestSubj: string; idAria: string; isDisabled: boolean; + validate?: (args: unknown) => boolean; } -export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: AddItemProps) => { +const MyEuiFormRow = styled(EuiFormRow)` + .euiFormRow__labelWrapper { + .euiText { + padding-right: 32px; + } + } +`; + +export const AddItem = ({ + addText, + dataTestSubj, + field, + idAria, + isDisabled, + validate, +}: AddItemProps) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - // const [items, setItems] = useState(['']); const [haveBeenKeyboardDeleted, setHaveBeenKeyboardDeleted] = useState(-1); const inputsRef = useRef([]); @@ -104,7 +128,7 @@ export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: Ad const values = field.value as string[]; return ( - - + + updateItem(e, index)} fullWidth {...euiFieldProps} /> + + removeItem(index)} aria-label={RuleI18n.DELETE} /> - } - onChange={e => updateItem(e, index)} - compressed - fullWidth - {...euiFieldProps} - /> + + + {values.length - 1 !== index && }
); })} - + {addText} - + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx new file mode 100644 index 0000000000000..09d0c1131ea10 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx @@ -0,0 +1,222 @@ +/* + * 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 { + EuiBadge, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiLink, + EuiText, + EuiListGroup, +} from '@elastic/eui'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { isEmpty } from 'lodash/fp'; +import React from 'react'; +import styled from 'styled-components'; + +import { esFilters } from '../../../../../../../../../../src/plugins/data/public'; + +import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; + +import { FilterLabel } from './filter_label'; +import * as i18n from './translations'; +import { BuildQueryBarDescription, BuildThreatsDescription, ListItems } from './types'; + +const EuiBadgeWrap = styled(EuiBadge)` + .euiBadge__text { + white-space: pre-wrap !important; + } +`; + +export const buildQueryBarDescription = ({ + field, + filters, + filterManager, + query, + savedId, + indexPatterns, +}: BuildQueryBarDescription): ListItems[] => { + let items: ListItems[] = []; + if (!isEmpty(filters)) { + filterManager.setFilters(filters); + items = [ + ...items, + { + title: <>{i18n.FILTERS_LABEL} , + description: ( + + {filterManager.getFilters().map((filter, index) => ( + + + {indexPatterns != null ? ( + + ) : ( + + )} + + + ))} + + ), + }, + ]; + } + if (!isEmpty(query.query)) { + items = [ + ...items, + { + title: <>{i18n.QUERY_LABEL} , + description: <>{query.query} , + }, + ]; + } + if (!isEmpty(savedId)) { + items = [ + ...items, + { + title: <>{i18n.SAVED_ID_LABEL} , + description: <>{savedId} , + }, + ]; + } + return items; +}; + +const ThreatsEuiFlexGroup = styled(EuiFlexGroup)` + .euiFlexItem { + margin-bottom: 0px; + } +`; + +const MyEuiListGroup = styled(EuiListGroup)` + padding: 0px; + .euiListGroupItem__button { + padding: 0px; + } +`; + +export const buildThreatsDescription = ({ + label, + threats, +}: BuildThreatsDescription): ListItems[] => { + if (threats.length > 0) { + return [ + { + title: label, + description: ( + + {threats.map((threat, index) => { + const tactic = tacticsOptions.find(t => t.name === threat.tactic.name); + return ( + + +
+ + {tactic != null ? tactic.text : ''} + +
+ { + const myTechnique = techniquesOptions.find(t => t.name === technique.name); + return { + label: myTechnique != null ? myTechnique.label : '', + href: technique.reference, + target: '_blank', + }; + })} + /> +
+
+ ); + })} +
+ ), + }, + ]; + } + return []; +}; + +export const buildStringArrayDescription = ( + label: string, + field: string, + values: string[] +): ListItems[] => { + if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + return [ + { + title: label, + description: ( + + {values.map((val: string) => + isEmpty(val) ? null : ( + + {val} + + ) + )} + + ), + }, + ]; + } + return []; +}; + +export const buildSeverityDescription = (label: string, value: string): ListItems[] => { + return [ + { + title: label, + description: ( + + {value} + + ), + }, + ]; +}; + +export const buildUrlsDescription = (label: string, values: string[]): ListItems[] => { + if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + return [ + { + title: label, + description: ( + ({ + label: val, + href: val, + iconType: 'link', + size: 'xs', + target: '_blank', + }))} + /> + ), + }, + ]; + } + return []; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index a05f43579e669..198756fc2336b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -4,19 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiBadge, - EuiDescriptionList, - EuiLoadingSpinner, - EuiFlexGroup, - EuiFlexItem, - EuiTextArea, - EuiLink, - EuiText, - EuiListGroup, -} from '@elastic/eui'; +import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiTextArea } from '@elastic/eui'; import { isEmpty, chunk, get, pick } from 'lodash/fp'; -import React, { memo, ReactNode, useState } from 'react'; +import React, { memo, useState } from 'react'; import styled from 'styled-components'; import { @@ -25,13 +15,19 @@ import { FilterManager, Query, } from '../../../../../../../../../../src/plugins/data/public'; +import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/search_super_select/translations'; import { useKibana } from '../../../../../lib/kibana'; -import { FilterLabel } from './filter_label'; -import { FormSchema } from '../shared_imports'; -import * as I18n from './translations'; - import { IMitreEnterpriseAttack } from '../../types'; -import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; +import { FieldValueTimeline } from '../pick_timeline'; +import { FormSchema } from '../shared_imports'; +import { ListItems } from './types'; +import { + buildQueryBarDescription, + buildSeverityDescription, + buildStringArrayDescription, + buildThreatsDescription, + buildUrlsDescription, +} from './helpers'; interface StepRuleDescriptionProps { direction?: 'row' | 'column'; @@ -40,29 +36,10 @@ interface StepRuleDescriptionProps { schema: FormSchema; } -const EuiBadgeWrap = styled(EuiBadge)` - .euiBadge__text { - white-space: pre-wrap !important; - } -`; - const EuiFlexItemWidth = styled(EuiFlexItem)<{ direction: string }>` ${props => (props.direction === 'row' ? 'width : 50%;' : 'width: 100%;')}; `; -const MyEuiListGroup = styled(EuiListGroup)` - padding: 0px; - .euiListGroupItem__button { - padding: 0px; - } -`; - -const ThreatsEuiFlexGroup = styled(EuiFlexGroup)` - .euiFlexItem { - margin-bottom: 0px; - } -`; - const MyEuiTextArea = styled(EuiTextArea)` max-width: 100%; height: 80px; @@ -87,9 +64,9 @@ const StepRuleDescriptionComponent: React.FC = ({ ); return ( - {chunk(Math.ceil(listItems.length / 2), listItems).map((chunckListItems, index) => ( + {chunk(Math.ceil(listItems.length / 2), listItems).map((chunkListItems, index) => ( - + ))} @@ -98,11 +75,6 @@ const StepRuleDescriptionComponent: React.FC = ({ export const StepRuleDescription = memo(StepRuleDescriptionComponent); -interface ListItems { - title: NonNullable; - description: NonNullable; -} - const buildListItems = ( data: unknown, schema: FormSchema, @@ -130,103 +102,23 @@ const getDescriptionItem = ( filterManager: FilterManager, indexPatterns?: IIndexPattern ): ListItems[] => { - if (field === 'useIndicesConfig') { - return []; - } else if (field === 'queryBar') { + if (field === 'queryBar') { const filters = get('queryBar.filters', value) as esFilters.Filter[]; const query = get('queryBar.query', value) as Query; const savedId = get('queryBar.saved_id', value); - let items: ListItems[] = []; - if (!isEmpty(filters)) { - filterManager.setFilters(filters); - items = [ - ...items, - { - title: <>{I18n.FILTERS_LABEL}, - description: ( - - {filterManager.getFilters().map((filter, index) => ( - - - {indexPatterns != null ? ( - - ) : ( - - )} - - - ))} - - ), - }, - ]; - } - if (!isEmpty(query.query)) { - items = [ - ...items, - { - title: <>{I18n.QUERY_LABEL}, - description: <>{query.query}, - }, - ]; - } - if (!isEmpty(savedId)) { - items = [ - ...items, - { - title: <>{I18n.SAVED_ID_LABEL}, - description: <>{savedId}, - }, - ]; - } - return items; + return buildQueryBarDescription({ + field, + filters, + filterManager, + query, + savedId, + indexPatterns, + }); } else if (field === 'threats') { const threats: IMitreEnterpriseAttack[] = get(field, value).filter( (threat: IMitreEnterpriseAttack) => threat.tactic.name !== 'none' ); - if (threats.length > 0) { - return [ - { - title: label, - description: ( - - {threats.map((threat, index) => { - const tactic = tacticsOptions.find(t => t.name === threat.tactic.name); - return ( - - -
- - {tactic != null ? tactic.text : ''} - -
- { - const myTechnique = techniquesOptions.find( - t => t.name === technique.name - ); - return { - label: myTechnique != null ? myTechnique.label : '', - href: technique.reference, - target: '_blank', - }; - })} - /> -
-
- ); - })} -
- ), - }, - ]; - } - return []; + return buildThreatsDescription({ label, threats }); } else if (field === 'description') { return [ { @@ -234,27 +126,23 @@ const getDescriptionItem = ( description: , }, ]; + } else if (field === 'references') { + const urls: string[] = get(field, value); + return buildUrlsDescription(label, urls); } else if (Array.isArray(get(field, value))) { const values: string[] = get(field, value); - if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { - return [ - { - title: label, - description: ( - - {values.map((val: string) => - isEmpty(val) ? null : ( - - {val} - - ) - )} - - ), - }, - ]; - } - return []; + return buildStringArrayDescription(label, field, values); + } else if (field === 'severity') { + const val: string = get(field, value); + return buildSeverityDescription(label, val); + } else if (field === 'timeline') { + const timeline = get(field, value) as FieldValueTimeline; + return [ + { + title: label, + description: timeline.title ?? DEFAULT_TIMELINE_TITLE, + }, + ]; } const description: string = get(field, value); if (!isEmpty(description)) { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts new file mode 100644 index 0000000000000..d32fbcd725d12 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ReactNode } from 'react'; + +import { + IIndexPattern, + esFilters, + FilterManager, + Query, +} from '../../../../../../../../../../src/plugins/data/public'; +import { IMitreEnterpriseAttack } from '../../types'; + +export interface ListItems { + title: NonNullable; + description: NonNullable; +} + +export interface BuildQueryBarDescription { + field: string; + filters: esFilters.Filter[]; + filterManager: FilterManager; + query: Query; + savedId: string; + indexPatterns?: IIndexPattern; +} + +export interface BuildThreatsDescription { + label: string; + threats: IMitreEnterpriseAttack[]; +} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.ts new file mode 100644 index 0000000000000..1202fe54ad194 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.ts @@ -0,0 +1,18 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; + +import { IMitreAttack } from '../../types'; + +export const isMitreAttackInvalid = ( + tacticName: string | null | undefined, + techniques: IMitreAttack[] | null | undefined +) => { + if (isEmpty(tacticName) || (tacticName !== 'none' && isEmpty(techniques))) { + return true; + } + return false; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index a777506ee12ae..97c4c2fdd050a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx @@ -8,27 +8,30 @@ import { EuiButtonEmpty, EuiButtonIcon, EuiFormRow, - EuiSelect, + EuiSuperSelect, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiComboBox, - EuiFormControlLayout, + EuiText, } from '@elastic/eui'; import { isEmpty, kebabCase, camelCase } from 'lodash/fp'; -import React, { ChangeEvent, useCallback } from 'react'; +import React, { useCallback } from 'react'; import styled from 'styled-components'; import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; -import * as RuleI18n from '../../translations'; +import * as Rulei18n from '../../translations'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; -import * as I18n from './translations'; +import { threatsDefault } from '../step_about_rule/default_value'; import { IMitreEnterpriseAttack } from '../../types'; +import { isMitreAttackInvalid } from './helpers'; +import * as i18n from './translations'; -const MyEuiFormControlLayout = styled(EuiFormControlLayout)` - &.euiFormControlLayout--compressed { - height: fit-content !important; - } +const MitreContainer = styled.div` + margin-top: 16px; +`; +const MyEuiSuperSelect = styled(EuiSuperSelect)` + width: 280px; `; interface AddItemProps { field: FieldHook; @@ -43,7 +46,12 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI const removeItem = useCallback( (index: number) => { const values = field.value as string[]; - field.setValue([...values.slice(0, index), ...values.slice(index + 1)]); + const newValues = [...values.slice(0, index), ...values.slice(index + 1)]; + if (isEmpty(newValues)) { + field.setValue(threatsDefault); + } else { + field.setValue(newValues); + } }, [field] ); @@ -61,9 +69,9 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI }, [field]); const updateTactic = useCallback( - (index: number, event: ChangeEvent) => { + (index: number, value: string) => { const values = field.value as IMitreEnterpriseAttack[]; - const { id, reference, name } = tacticsOptions.find(t => t.value === event.target.value) || { + const { id, reference, name } = tacticsOptions.find(t => t.value === value) || { id: '', name: '', reference: '', @@ -97,75 +105,104 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI const values = field.value as IMitreEnterpriseAttack[]; + const getSelectTactic = (tacticName: string, index: number, disabled: boolean) => ( + {i18n.TACTIC_PLACEHOLDER}, + value: 'none', + disabled, + }, + ] + : []), + ...tacticsOptions.map(t => ({ + inputDisplay: <>{t.text}, + value: t.value, + disabled, + })), + ]} + aria-label="" + onChange={updateTactic.bind(null, index)} + fullWidth={false} + valueOfSelected={camelCase(tacticName)} + /> + ); + + const getSelectTechniques = (item: IMitreEnterpriseAttack, index: number, disabled: boolean) => { + const invalid = isMitreAttackInvalid(item.tactic.name, item.techniques); + return ( + + + t.tactics.includes(kebabCase(item.tactic.name)))} + selectedOptions={item.techniques} + onChange={updateTechniques.bind(null, index)} + isDisabled={disabled} + fullWidth={true} + isInvalid={invalid} + /> + {invalid && ( + +

{errorMessage}

+
+ )} +
+ + removeItem(index)} + aria-label={Rulei18n.DELETE} + /> + +
+ ); + }; + return ( - - <> - {values.map((item, index) => { - const euiSelectFieldProps = { - disabled: isDisabled, - }; - return ( -
- - - ({ text: t.text, value: t.value })), - ]} - aria-label="" - onChange={updateTactic.bind(null, index)} - prepend={I18n.TACTIC} - compressed - fullWidth={false} - value={camelCase(item.tactic.name)} - {...euiSelectFieldProps} - /> - - - - - t.tactics.includes(kebabCase(item.tactic.name)) - )} - selectedOptions={item.techniques} - onChange={updateTechniques.bind(null, index)} - isDisabled={isDisabled} - fullWidth={true} - /> - - - - removeItem(index)} - aria-label={RuleI18n.DELETE} - /> - - - {values.length - 1 !== index && } -
- ); - })} - - {I18n.ADD_MITRE_ATTACK} - - -
+ + {values.map((item, index) => ( +
+ + + {index === 0 ? ( + + <>{getSelectTactic(item.tactic.name, index, isDisabled)} + + ) : ( + getSelectTactic(item.tactic.name, index, isDisabled) + )} + + + {index === 0 ? ( + + <>{getSelectTechniques(item, index, isDisabled)} + + ) : ( + getSelectTechniques(item, index, isDisabled) + )} + + + {values.length - 1 !== index && } +
+ ))} + + {i18n.ADD_MITRE_ATTACK} + +
); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts index 22ee6cc3ef911..dd4c55c1503ec 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts @@ -7,13 +7,13 @@ import { i18n } from '@kbn/i18n'; export const TACTIC = i18n.translate('xpack.siem.detectionEngine.mitreAttack.tacticsDescription', { - defaultMessage: 'Tactic', + defaultMessage: 'tactic', }); -export const TECHNIQUES = i18n.translate( +export const TECHNIQUE = i18n.translate( 'xpack.siem.detectionEngine.mitreAttack.techniquesDescription', { - defaultMessage: 'Techniques', + defaultMessage: 'technique', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx new file mode 100644 index 0000000000000..873e0c2184c61 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFormRow } from '@elastic/eui'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { SearchTimelineSuperSelect } from '../../../../../components/timeline/search_super_select'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; + +export interface FieldValueTimeline { + id: string | null; + title: string | null; +} + +interface QueryBarDefineRuleProps { + dataTestSubj: string; + field: FieldHook; + idAria: string; + isDisabled: boolean; +} + +export const PickTimeline = ({ + dataTestSubj, + field, + idAria, + isDisabled = false, +}: QueryBarDefineRuleProps) => { + const [timelineId, setTimelineId] = useState(null); + const [timelineTitle, setTimelineTitle] = useState(null); + + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + useEffect(() => { + const { id, title } = field.value as FieldValueTimeline; + if (timelineTitle !== title && timelineId !== id) { + setTimelineId(id); + setTimelineTitle(title); + } + }, [field.value]); + + const handleOnTimelineChange = useCallback( + (title: string, id: string | null) => { + if (id === null) { + field.setValue({ id, title: null }); + } else if (timelineTitle !== title && timelineId !== id) { + field.setValue({ id, title }); + } + }, + [field] + ); + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx index c294ec24c4cb7..3e39beb6e61b7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx @@ -6,7 +6,7 @@ import { EuiFormRow, EuiMutationObserver } from '@elastic/eui'; import { isEqual } from 'lodash/fp'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Subscription } from 'rxjs'; import styled from 'styled-components'; @@ -19,11 +19,18 @@ import { SavedQueryTimeFilter, } from '../../../../../../../../../../src/plugins/data/public'; +import { BrowserFields } from '../../../../../containers/source'; +import { OpenTimelineModal } from '../../../../../components/open_timeline/open_timeline_modal'; +import { ActionTimelineToShow } from '../../../../../components/open_timeline/types'; import { QueryBar } from '../../../../../components/query_bar'; +import { buildGlobalQuery } from '../../../../../components/timeline/helpers'; +import { getDataProviderFilter } from '../../../../../components/timeline/query_bar'; +import { convertKueryToElasticSearchQuery } from '../../../../../lib/keury'; import { useKibana } from '../../../../../lib/kibana'; +import { TimelineModel } from '../../../../../store/timeline/model'; import { useSavedQueryServices } from '../../../../../utils/saved_query_services'; - import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; +import * as i18n from './translations'; export interface FieldValueQueryBar { filters: esFilters.Filter[]; @@ -31,11 +38,14 @@ export interface FieldValueQueryBar { saved_id: string | null; } interface QueryBarDefineRuleProps { + browserFields: BrowserFields; dataTestSubj: string; field: FieldHook; idAria: string; isLoading: boolean; indexPattern: IIndexPattern; + onCloseTimelineSearch: () => void; + openTimelineSearch: boolean; resizeParentContainer?: (height: number) => void; } @@ -56,14 +66,18 @@ const StyledEuiFormRow = styled(EuiFormRow)` // TODO need to add disabled in the SearchBar export const QueryBarDefineRule = ({ + browserFields, dataTestSubj, field, idAria, indexPattern, isLoading = false, + onCloseTimelineSearch, + openTimelineSearch = false, resizeParentContainer, }: QueryBarDefineRuleProps) => { const [originalHeight, setOriginalHeight] = useState(-1); + const [loadingTimeline, setLoadingTimeline] = useState(false); const [savedQuery, setSavedQuery] = useState(null); const [queryDraft, setQueryDraft] = useState({ query: '', language: 'kuery' }); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); @@ -168,6 +182,38 @@ export const QueryBarDefineRule = ({ [field.value] ); + const onCloseTimelineModal = useCallback(() => { + setLoadingTimeline(true); + onCloseTimelineSearch(); + }, [onCloseTimelineSearch]); + + const onOpenTimeline = useCallback( + (timeline: TimelineModel) => { + setLoadingTimeline(false); + const newQuery = { + query: timeline.kqlQuery.filterQuery?.kuery?.expression ?? '', + language: timeline.kqlQuery.filterQuery?.kuery?.kind ?? 'kuery', + }; + const dataProvidersDsl = + timeline.dataProviders != null && timeline.dataProviders.length > 0 + ? convertKueryToElasticSearchQuery( + buildGlobalQuery(timeline.dataProviders, browserFields), + indexPattern + ) + : ''; + const newFilters = timeline.filters ?? []; + field.setValue({ + filters: + dataProvidersDsl !== '' + ? [...newFilters, getDataProviderFilter(dataProvidersDsl)] + : newFilters, + query: newQuery, + saved_id: '', + }); + }, + [browserFields, field, indexPattern] + ); + const onMutation = (event: unknown, observer: unknown) => { if (resizeParentContainer != null) { const suggestionContainer = document.getElementById('kbnTypeahead__items'); @@ -189,39 +235,51 @@ export const QueryBarDefineRule = ({ } }; + const actionTimelineToHide = useMemo(() => ['duplicate'], []); + return ( - - + - {mutationRef => ( -
- -
- )} -
-
+ + {mutationRef => ( +
+ +
+ )} +
+ + {openTimelineSearch ? ( + + ) : null} + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/translations.tsx new file mode 100644 index 0000000000000..9b14e4f8599da --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/translations.tsx @@ -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 { i18n } from '@kbn/i18n'; + +export const IMPORT_TIMELINE_MODAL = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.importTimelineModalTitle', + { + defaultMessage: 'Import query from saved timeline', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx index 2e57ff8ba2c4f..8097c27cddfe8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFieldNumber, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiFieldNumber, EuiFormRow, EuiSelect } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; @@ -32,6 +32,10 @@ const StyledEuiFormRow = styled(EuiFormRow)` } `; +const MyEuiSelect = styled(EuiSelect)` + width: auto; +`; + export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: ScheduleItemProps) => { const [timeType, setTimeType] = useState('s'); const [timeVal, setTimeVal] = useState(0); @@ -79,22 +83,33 @@ export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: Schedu // EUI missing some props const rest = { disabled: isDisabled }; + const label = useMemo( + () => ( + + + {field.label} + + + {field.labelAppend} + + + ), + [field.label, field.labelAppend] + ); return ( } - compressed fullWidth min={0} onChange={onChangeTimeVal} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx index 48ff0d80d0398..3ec5bf1a12eb0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx @@ -27,7 +27,7 @@ const RuleStatusIconStyled = styled.div` const RuleStatusIconComponent: React.FC = ({ name, type }) => { const theme = useEuiTheme(); - const color = type === 'passive' ? theme.euiColorLightestShade : theme.euiColorDarkestShade; + const color = type === 'passive' ? theme.euiColorLightestShade : theme.euiColorPrimary; return ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.ts deleted file mode 100644 index 7d6e434bcc8c6..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.ts +++ /dev/null @@ -1,28 +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 I18n from './translations'; - -export type SeverityValue = 'low' | 'medium' | 'high' | 'critical'; - -interface SeverityOptionItem { - value: SeverityValue; - text: string; -} - -export const severityOptions: SeverityOptionItem[] = [ - { value: 'low', text: I18n.LOW }, - { value: 'medium', text: I18n.MEDIUM }, - { value: 'high', text: I18n.HIGH }, - { value: 'critical', text: I18n.CRITICAL }, -]; - -export const defaultRiskScoreBySeverity: Record = { - low: 21, - medium: 47, - high: 73, - critical: 99, -}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx new file mode 100644 index 0000000000000..9fb64189ebd1a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiHealth } from '@elastic/eui'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React from 'react'; +import * as I18n from './translations'; + +export type SeverityValue = 'low' | 'medium' | 'high' | 'critical'; + +interface SeverityOptionItem { + value: SeverityValue; + inputDisplay: React.ReactElement; +} + +export const severityOptions: SeverityOptionItem[] = [ + { + value: 'low', + inputDisplay: {I18n.LOW}, + }, + { + value: 'medium', + inputDisplay: {I18n.MEDIUM} , + }, + { + value: 'high', + inputDisplay: {I18n.HIGH} , + }, + { + value: 'critical', + inputDisplay: {I18n.CRITICAL} , + }, +]; + +export const defaultRiskScoreBySeverity: Record = { + low: 21, + medium: 47, + high: 73, + critical: 99, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts index c0c5ae77a1960..328c4a0f96066 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts @@ -6,6 +6,14 @@ import { AboutStepRule } from '../../types'; +export const threatsDefault = [ + { + framework: 'MITRE ATT&CK', + tactic: { id: 'none', name: 'none', reference: 'none' }, + techniques: [], + }, +]; + export const stepAboutDefaultValue: AboutStepRule = { name: '', description: '', @@ -15,11 +23,9 @@ export const stepAboutDefaultValue: AboutStepRule = { references: [''], falsePositives: [''], tags: [], - threats: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: 'none', name: 'none', reference: 'none' }, - techniques: [], - }, - ], + timeline: { + id: null, + title: null, + }, + threats: threatsDefault, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/helpers.ts new file mode 100644 index 0000000000000..99b01c8b22974 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/helpers.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash/fp'; + +const urlExpression = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi; + +export const isUrlInvalid = (url: string | null | undefined) => { + if (!isEmpty(url) && url != null && url.match(urlExpression) == null) { + return true; + } + return false; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index e266c0b9ab47d..8956776dcd3b2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -7,17 +7,21 @@ import { EuiButton, EuiHorizontalRule, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEqual, get } from 'lodash/fp'; import React, { memo, useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; import { RuleStepProps, RuleStep, AboutStepRule } from '../../types'; import * as RuleI18n from '../../translations'; -import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; import { AddItem } from '../add_item_form'; +import { StepRuleDescription } from '../description_step'; +import { AddMitreThreat } from '../mitre'; +import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; + import { defaultRiskScoreBySeverity, severityOptions, SeverityValue } from './data'; import { stepAboutDefaultValue } from './default_value'; +import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; -import { StepRuleDescription } from '../description_step'; -import { AddMitreThreat } from '../mitre'; +import { PickTimeline } from '../pick_timeline'; const CommonUseField = getUseField({ component: Field }); @@ -25,6 +29,10 @@ interface StepAboutRuleProps extends RuleStepProps { defaultValues?: AboutStepRule | null; } +const TagContainer = styled.div` + margin-top: 16px; +`; + export const StepAboutRule = memo( ({ defaultValues, @@ -90,7 +98,6 @@ export const StepAboutRule = memo( idAria: 'detectionEngineStepAboutRuleName', 'data-test-subj': 'detectionEngineStepAboutRuleName', euiFieldProps: { - compressed: true, fullWidth: false, disabled: isLoading, }, @@ -99,11 +106,9 @@ export const StepAboutRule = memo( ( idAria: 'detectionEngineStepAboutRuleSeverity', 'data-test-subj': 'detectionEngineStepAboutRuleSeverity', euiFieldProps: { - compressed: true, fullWidth: false, disabled: isLoading, options: severityOptions, @@ -129,29 +133,38 @@ export const StepAboutRule = memo( euiFieldProps: { max: 100, min: 0, - compressed: true, fullWidth: false, disabled: isLoading, options: severityOptions, + showTicks: true, + tickInterval: 25, }, }} /> + ( path="threats" component={AddMitreThreat} componentProps={{ - compressed: true, idAria: 'detectionEngineStepAboutRuleMitreThreats', isDisabled: isLoading, dataTestSubj: 'detectionEngineStepAboutRuleMitreThreats', }} /> - + + + {({ severity }) => { const newRiskScore = defaultRiskScoreBySeverity[severity as SeverityValue]; @@ -202,7 +216,7 @@ export const StepAboutRule = memo( > - {myStepData.isNew ? RuleI18n.CONTINUE : RuleI18n.UPDATE} + {RuleI18n.CONTINUE} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index c72312bb90836..9355f1c8bfefa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -6,7 +6,6 @@ import { EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash/fp'; import React from 'react'; import * as RuleI18n from '../../translations'; @@ -18,6 +17,8 @@ import { ValidationFunc, ERROR_CODE, } from '../shared_imports'; +import { isMitreAttackInvalid } from '../mitre/helpers'; +import { isUrlInvalid } from './helpers'; import * as I18n from './translations'; const { emptyField } = fieldValidators; @@ -63,7 +64,7 @@ export const schema: FormSchema = { ], }, severity: { - type: FIELD_TYPES.SELECT, + type: FIELD_TYPES.SUPER_SELECT, label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldSeverityLabel', { @@ -92,6 +93,14 @@ export const schema: FormSchema = { } ), }, + timeline: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateLabel', + { + defaultMessage: 'Timeline template', + } + ), + }, references: { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel', @@ -100,6 +109,28 @@ export const schema: FormSchema = { } ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, + validations: [ + { + validator: ( + ...args: Parameters + ): ReturnType> | undefined => { + const [{ value, path }] = args; + let hasError = false; + (value as string[]).forEach(url => { + if (isUrlInvalid(url)) { + hasError = true; + } + }); + return hasError + ? { + code: 'ERR_FIELD_FORMAT', + path, + message: I18n.URL_FORMAT_INVALID, + } + : undefined; + }, + }, + ], }, falsePositives: { label: i18n.translate( @@ -126,7 +157,7 @@ export const schema: FormSchema = { const [{ value, path }] = args; let hasError = false; (value as IMitreEnterpriseAttack[]).forEach(v => { - if (isEmpty(v.tactic.name) || (v.tactic.name !== 'none' && isEmpty(v.techniques))) { + if (isMitreAttackInvalid(v.tactic.name, v.techniques)) { hasError = true; } }); @@ -146,6 +177,13 @@ export const schema: FormSchema = { label: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTagsLabel', { defaultMessage: 'Tags', }), + helpText: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTagsHelpText', + { + defaultMessage: + 'Type one or more custom identifying tags for this rule. Press enter after each tag to begin a new one.', + } + ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts index 017d4fe6fdf49..052986480e9ab 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts @@ -54,3 +54,10 @@ export const CUSTOM_MITRE_ATTACK_TECHNIQUES_REQUIRED = i18n.translate( defaultMessage: 'At least one Technique is required with a Tactic.', } ); + +export const URL_FORMAT_INVALID = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.referencesUrlInvalidError', + { + defaultMessage: 'Url is invalid format', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index cc4e959cc9c78..ecd2ce442238f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -4,8 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; -import { isEqual, get } from 'lodash/fp'; +import { + EuiButtonEmpty, + EuiHorizontalRule, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import { isEmpty, isEqual, get } from 'lodash/fp'; import React, { memo, useCallback, useState, useEffect } from 'react'; import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/public'; @@ -18,7 +24,7 @@ import { StepRuleDescription } from '../description_step'; import { QueryBarDefineRule } from '../query_bar'; import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; import { schema } from './schema'; -import * as I18n from './translations'; +import * as i18n from './translations'; const CommonUseField = getUseField({ component: Field }); @@ -34,7 +40,6 @@ const stepDefineDefaultValue = { filters: [], saved_id: null, }, - useIndicesConfig: 'true', }; const getStepDefaultValue = ( @@ -45,7 +50,6 @@ const getStepDefaultValue = ( return { ...defaultValues, isNew: false, - useIndicesConfig: `${isEqual(defaultValues.index, indicesConfig)}`, }; } else { return { @@ -66,13 +70,22 @@ export const StepDefineRule = memo( setForm, setStepData, }) => { - const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(''); + const [openTimelineSearch, setOpenTimelineSearch] = useState(false); + const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); + const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( + defaultValues != null ? defaultValues.index : indicesConfig ?? [] + ); const [ - { indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, - setIndices, - ] = useFetchIndexPatterns(defaultValues != null ? defaultValues.index : indicesConfig ?? []); - const [myStepData, setMyStepData] = useState(stepDefineDefaultValue); + { + browserFields, + indexPatterns: indexPatternQueryBar, + isLoading: indexPatternLoadingQueryBar, + }, + ] = useFetchIndexPatterns(mylocalIndicesConfig); + const [myStepData, setMyStepData] = useState( + getStepDefaultValue(indicesConfig, null) + ); const { form } = useForm({ defaultValue: myStepData, @@ -96,7 +109,7 @@ export const StepDefineRule = memo( const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); if (!isEqual(myDefaultValues, myStepData)) { setMyStepData(myDefaultValues); - setLocalUseIndicesConfig(myDefaultValues.useIndicesConfig); + setLocalUseIndicesConfig(isEqual(myDefaultValues.index, indicesConfig)); if (!isReadOnlyView) { Object.keys(schema).forEach(key => { const val = get(key, myDefaultValues); @@ -115,6 +128,19 @@ export const StepDefineRule = memo( } }, [form]); + const handleResetIndices = useCallback(() => { + const indexField = form.getFields().index; + indexField.setValue(indicesConfig); + }, [form, indicesConfig]); + + const handleOpenTimelineSearch = useCallback(() => { + setOpenTimelineSearch(true); + }, []); + + const handleCloseTimelineSearch = useCallback(() => { + setOpenTimelineSearch(false); + }, []); + return isReadOnlyView && myStepData != null ? ( ( ) : ( <>
- + {i18n.RESET_DEFAULT_INDEX} + + ) : null, + }} componentProps={{ idAria: 'detectionEngineStepDefineRuleIndices', 'data-test-subj': 'detectionEngineStepDefineRuleIndices', euiFieldProps: { - compressed: true, fullWidth: true, isDisabled: isLoading, + placeholder: '', }, }} /> + {i18n.IMPORT_TIMELINE_QUERY} + + ), + }} component={QueryBarDefineRule} componentProps={{ - compressed: true, + browserFields, loading: indexPatternLoadingQueryBar, idAria: 'detectionEngineStepDefineRuleQueryBar', indexPattern: indexPatternQueryBar, isDisabled: isLoading, isLoading: indexPatternLoadingQueryBar, dataTestSubj: 'detectionEngineStepDefineRuleQueryBar', + openTimelineSearch, + onCloseTimelineSearch: handleCloseTimelineSearch, resizeParentContainer, }} /> - - {({ useIndicesConfig }) => { - if (localUseIndicesConfig !== useIndicesConfig) { - const indexField = form.getFields().index; - if ( - indexField != null && - useIndicesConfig === 'true' && - !isEqual(indexField.value, indicesConfig) - ) { - indexField.setValue(indicesConfig); - setIndices(indicesConfig); - } else if ( - indexField != null && - useIndicesConfig === 'false' && - isEqual(indexField.value, indicesConfig) - ) { - indexField.setValue([]); - setIndices([]); + + {({ index }) => { + if (index != null) { + if (isEqual(index, indicesConfig) && !localUseIndicesConfig) { + setLocalUseIndicesConfig(true); + } + if (!isEqual(index, indicesConfig) && localUseIndicesConfig) { + setLocalUseIndicesConfig(false); + } + if (index != null && !isEmpty(index) && !isEqual(index, mylocalIndicesConfig)) { + setMyLocalIndicesConfig(index); } - setLocalUseIndicesConfig(useIndicesConfig); } - return null; }} @@ -208,7 +223,7 @@ export const StepDefineRule = memo( > - {myStepData.isNew ? RuleI18n.CONTINUE : RuleI18n.UPDATE} + {RuleI18n.CONTINUE} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index 9b54ada8227c6..dbd7e3b3f96aa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -10,7 +10,6 @@ import { isEmpty } from 'lodash/fp'; import React from 'react'; import { esKuery } from '../../../../../../../../../../src/plugins/data/public'; -import * as RuleI18n from '../../translations'; import { FieldValueQueryBar } from '../query_bar'; import { ERROR_CODE, @@ -19,33 +18,27 @@ import { FormSchema, ValidationFunc, } from '../shared_imports'; -import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY } from './translations'; +import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations'; const { emptyField } = fieldValidators; export const schema: FormSchema = { - useIndicesConfig: { - type: FIELD_TYPES.RADIO_GROUP, + index: { + type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( - 'xpack.siem.detectionEngine.createRule.stepDefineRule.fieldIndicesTypeLabel', + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fiedIndexPatternsLabel', { - defaultMessage: 'Indices type', + defaultMessage: 'Index patterns', } ), - }, - index: { - type: FIELD_TYPES.COMBO_BOX, - label: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.fiedIndicesLabel', { - defaultMessage: 'Indices', - }), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + helpText: {INDEX_HELPER_TEXT}, validations: [ { validator: emptyField( i18n.translate( 'xpack.siem.detectionEngine.createRule.stepDefineRule.outputIndiceNameFieldRequiredError', { - defaultMessage: 'An output indice name for signals is required.', + defaultMessage: 'Index patterns for signals is required.', } ) ), diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx index 0050c59a4a2c8..8394f090e346c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx @@ -33,3 +33,25 @@ export const CUSTOM_INDICES = i18n.translate( defaultMessage: 'Provide custom list of indices', } ); + +export const INDEX_HELPER_TEXT = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.indicesHelperDescription', + { + defaultMessage: + 'Enter the pattern of Elasticsearch indices where you would like this rule to run. By default, these will include index patterns defined in SIEM advanced settings.', + } +); + +export const RESET_DEFAULT_INDEX = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.resetDefaultIndicesButton', + { + defaultMessage: 'Reset to default index patterns', + } +); + +export const IMPORT_TIMELINE_QUERY = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.importTimelineQueryButton', + { + defaultMessage: 'Import query from saved timeline', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index 6f7e49bc8ab9a..35b8ca6650bf6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -92,7 +92,6 @@ export const StepScheduleRule = memo( path="interval" component={ScheduleItem} componentProps={{ - compressed: true, idAria: 'detectionEngineStepScheduleRuleInterval', isDisabled: isLoading, dataTestSubj: 'detectionEngineStepScheduleRuleInterval', @@ -102,7 +101,6 @@ export const StepScheduleRule = memo( path="from" component={ScheduleItem} componentProps={{ - compressed: true, idAria: 'detectionEngineStepScheduleRuleFrom', isDisabled: isLoading, dataTestSubj: 'detectionEngineStepScheduleRuleFrom', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index a25ccce569dd4..12bbdbdfff3e9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -40,14 +40,14 @@ const getTimeTypeValue = (time: string): { unit: string; value: number } => { }; const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => { - const { queryBar, useIndicesConfig, isNew, ...rest } = defineStepData; + const { queryBar, isNew, ...rest } = defineStepData; const { filters, query, saved_id: savedId } = queryBar; return { ...rest, language: query.language, filters, query: query.query as string, - ...(savedId != null ? { saved_id: savedId } : {}), + ...(savedId != null && savedId !== '' ? { saved_id: savedId } : {}), }; }; @@ -72,11 +72,21 @@ const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRul }; const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson => { - const { falsePositives, references, riskScore, threats, isNew, ...rest } = aboutStepData; + const { + falsePositives, + references, + riskScore, + threats, + timeline, + isNew, + ...rest + } = aboutStepData; return { false_positives: falsePositives.filter(item => !isEmpty(item)), references: references.filter(item => !isEmpty(item)), risk_score: riskScore, + timeline_id: timeline.id, + timeline_title: timeline.title, threats: threats .filter(threat => threat.tactic.name !== 'none') .map(threat => ({ @@ -97,7 +107,7 @@ export const formatRule = ( scheduleData: ScheduleStepRule, ruleId?: string ): NewRule => { - const type: FormatRuleType = defineStepData.queryBar.saved_id != null ? 'saved_query' : 'query'; + const type: FormatRuleType = !isEmpty(defineStepData.queryBar.saved_id) ? 'saved_query' : 'query'; const persistData = { type, ...formatDefineStepData(defineStepData), diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index 3e8dbeba89546..848b17aadbff4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -12,12 +12,13 @@ import styled from 'styled-components'; import { HeaderPage } from '../../../../components/header_page'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine'; import { WrapperPage } from '../../../../components/wrapper_page'; +import { usePersistRule } from '../../../../containers/detection_engine/rules'; +import { SpyRoute } from '../../../../utils/route/spy_routes'; import { AccordionTitle } from '../components/accordion_title'; +import { FormData, FormHook } from '../components/shared_imports'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; -import { usePersistRule } from '../../../../containers/detection_engine/rules'; -import { SpyRoute } from '../../../../utils/route/spy_routes'; import * as RuleI18n from '../translations'; import { AboutStepRule, DefineStepRule, RuleStep, RuleStepData, ScheduleStepRule } from '../types'; import { formatRule } from './helpers'; @@ -28,17 +29,43 @@ const stepsRuleOrder = [RuleStep.defineRule, RuleStep.aboutRule, RuleStep.schedu const ResizeEuiPanel = styled(EuiPanel)<{ height?: number; }>` + .euiAccordion__iconWrapper { + display: none; + } .euiAccordion__childWrapper { height: ${props => (props.height !== -1 ? `${props.height}px !important` : 'auto')}; } + .euiAccordion__button { + cursor: default !important; + &:hover { + text-decoration: none !important; + } + } +`; + +const MyEuiPanel = styled(EuiPanel)` + .euiAccordion__iconWrapper { + display: none; + } + .euiAccordion__button { + cursor: default !important; + &:hover { + text-decoration: none !important; + } + } `; export const CreateRuleComponent = React.memo(() => { const [heightAccordion, setHeightAccordion] = useState(-1); - const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); + const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); const scheduleRuleRef = useRef(null); + const stepsForm = useRef | null>>({ + [RuleStep.defineRule]: null, + [RuleStep.aboutRule]: null, + [RuleStep.scheduleRule]: null, + }); const stepsData = useRef>({ [RuleStep.defineRule]: { isValid: false, data: {} }, [RuleStep.aboutRule]: { isValid: false, data: {} }, @@ -57,11 +84,17 @@ export const CreateRuleComponent = React.memo(() => { if (isValid) { const stepRuleIdx = stepsRuleOrder.findIndex(item => step === item); if ([0, 1].includes(stepRuleIdx)) { - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [step]: true, - }); - if (openAccordionId !== stepsRuleOrder[stepRuleIdx + 1]) { + if (isStepRuleInReadOnlyView[stepsRuleOrder[stepRuleIdx + 1]]) { + setIsStepRuleInEditView({ + ...isStepRuleInReadOnlyView, + [step]: true, + [stepsRuleOrder[stepRuleIdx + 1]]: false, + }); + } else if (openAccordionId !== stepsRuleOrder[stepRuleIdx + 1]) { + setIsStepRuleInEditView({ + ...isStepRuleInReadOnlyView, + [step]: true, + }); openCloseAccordion(stepsRuleOrder[stepRuleIdx + 1]); setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); } @@ -80,9 +113,13 @@ export const CreateRuleComponent = React.memo(() => { } } }, - [openAccordionId, stepsData.current, setRule] + [isStepRuleInReadOnlyView, openAccordionId, stepsData.current, setRule] ); + const setStepsForm = useCallback((step: RuleStep, form: FormHook) => { + stepsForm.current[step] = form; + }, []); + const getAccordionType = useCallback( (accordionId: RuleStep) => { if (accordionId === openAccordionId) { @@ -135,42 +172,38 @@ export const CreateRuleComponent = React.memo(() => { (id: RuleStep, isOpen: boolean) => { const activeRuleIdx = stepsRuleOrder.findIndex(step => step === openAccordionId); const stepRuleIdx = stepsRuleOrder.findIndex(step => step === id); - const isLatestStepsRuleValid = - stepRuleIdx === 0 - ? true - : stepsRuleOrder - .filter((stepRule, index) => index < stepRuleIdx) - .every(stepRule => stepsData.current[stepRule].isValid); - if (stepRuleIdx < activeRuleIdx && !isOpen) { + if ((id === openAccordionId || stepRuleIdx < activeRuleIdx) && !isOpen) { openCloseAccordion(id); } else if (stepRuleIdx >= activeRuleIdx) { if ( - openAccordionId != null && openAccordionId !== id && !stepsData.current[openAccordionId].isValid && !isStepRuleInReadOnlyView[id] && isOpen ) { openCloseAccordion(id); - } else if (!isLatestStepsRuleValid && isOpen) { - openCloseAccordion(id); - } else if (id !== openAccordionId && isOpen) { - setOpenAccordionId(id); } } }, - [isStepRuleInReadOnlyView, openAccordionId] + [isStepRuleInReadOnlyView, openAccordionId, stepsData] ); const manageIsEditable = useCallback( - (id: RuleStep) => { - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [id]: false, - }); + async (id: RuleStep) => { + const activeForm = await stepsForm.current[openAccordionId]?.submit(); + if (activeForm != null && activeForm?.isValid) { + setOpenAccordionId(id); + openCloseAccordion(openAccordionId); + + setIsStepRuleInEditView({ + ...isStepRuleInReadOnlyView, + [openAccordionId]: openAccordionId === RuleStep.scheduleRule ? false : true, + [id]: false, + }); + } }, - [isStepRuleInReadOnlyView] + [isStepRuleInReadOnlyView, openAccordionId] ); if (isSaved) { @@ -201,7 +234,7 @@ export const CreateRuleComponent = React.memo(() => { size="xs" onClick={manageIsEditable.bind(null, RuleStep.defineRule)} > - {`Edit`} + {i18n.EDIT_RULE} ) } @@ -210,13 +243,14 @@ export const CreateRuleComponent = React.memo(() => { setHeightAccordion(height)} /> - + { size="xs" onClick={manageIsEditable.bind(null, RuleStep.aboutRule)} > - {`Edit`} + {i18n.EDIT_RULE} ) } @@ -240,12 +274,13 @@ export const CreateRuleComponent = React.memo(() => { - + - + { size="xs" onClick={manageIsEditable.bind(null, RuleStep.scheduleRule)} > - {`Edit`} + {i18n.EDIT_RULE} ) } @@ -269,10 +304,11 @@ export const CreateRuleComponent = React.memo(() => { - + diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts index 884f3f3741228..329bcc286fb70 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts @@ -9,3 +9,7 @@ import { i18n } from '@kbn/i18n'; export const PAGE_TITLE = i18n.translate('xpack.siem.detectionEngine.createRule.pageTitle', { defaultMessage: 'Create new rule', }); + +export const EDIT_RULE = i18n.translate('xpack.siem.detectionEngine.createRule.editRuleButton', { + defaultMessage: 'Edit', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 1bc2bc24517e3..4d887c7cb5b6e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -113,7 +113,10 @@ export const RuleDetailsComponent = memo(({ signalsIn (({ signalsIn diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx index 8e32f82dff0b1..10b7f0e832f19 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx @@ -49,6 +49,7 @@ interface ScheduleStepRuleForm extends StepRuleForm { export const EditRuleComponent = memo(() => { const { ruleId } = useParams(); const [loading, rule] = useRule(ruleId); + const [initForm, setInitForm] = useState(false); const [myAboutRuleForm, setMyAboutRuleForm] = useState({ data: null, @@ -249,7 +250,7 @@ export const EditRuleComponent = memo(() => { }, []); if (isSaved || (rule != null && rule.immutable)) { - return ; + return ; } return ( @@ -257,7 +258,7 @@ export const EditRuleComponent = memo(() => { { responsive={false} > - + {i18n.CANCEL} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 46301ae808919..47b5c1051bcfc 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -33,7 +33,6 @@ export const getStepsData = ({ filters: rule.filters as esFilters.Filter[], saved_id: rule.saved_id ?? null, }, - useIndicesConfig: 'true', } : null; const aboutRuleData: AboutStepRule | null = @@ -45,6 +44,10 @@ export const getStepsData = ({ threats: rule.threats as IMitreEnterpriseAttack[], falsePositives: rule.false_positives, riskScore: rule.risk_score, + timeline: { + id: rule.timeline_id ?? null, + title: rule.timeline_title ?? null, + }, } : null; const scheduleRuleData: ScheduleStepRule | null = diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx index 8b4cc2a213589..ef67f0a7d22c6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx @@ -8,6 +8,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { DETECTION_ENGINE_PAGE_NAME } from '../../../components/link_to/redirect_to_detection_engine'; import { FormattedRelativePreferenceDate } from '../../../components/formatted_date'; import { getEmptyTagValue } from '../../../components/empty_value'; import { HeaderPage } from '../../../components/header_page'; @@ -32,7 +33,10 @@ export const RulesComponent = React.memo(() => { /> { - + {i18n.ADD_NEW_RULE} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index 9b535034810bd..ec4206623bad9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -8,6 +8,7 @@ import { esFilters } from '../../../../../../../../src/plugins/data/common'; import { Rule } from '../../../containers/detection_engine/rules'; import { FieldValueQueryBar } from './components/query_bar'; import { FormData, FormHook } from './components/shared_imports'; +import { FieldValueTimeline } from './components/pick_timeline'; export interface EuiBasicTableSortTypes { field: string; @@ -76,11 +77,11 @@ export interface AboutStepRule extends StepRuleData { references: string[]; falsePositives: string[]; tags: string[]; + timeline: FieldValueTimeline; threats: IMitreEnterpriseAttack[]; } export interface DefineStepRule extends StepRuleData { - useIndicesConfig: string; index: string[]; queryBar: FieldValueQueryBar; } @@ -108,6 +109,8 @@ export interface AboutStepRuleJson { references: string[]; false_positives: string[]; tags: string[]; + timeline_id: string | null; + timeline_title: string | null; threats: IMitreEnterpriseAttack[]; } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index edf196b96f5d0..f6ac0435cd7c1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -52,6 +52,7 @@ export const fullRuleAlertParamsRest = (): RuleAlertParamsRest => ({ created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', timeline_id: 'timeline-id', + timeline_title: 'timeline-title', }); export const typicalPayload = (): Partial => ({ @@ -271,6 +272,7 @@ export const getResult = (): RuleAlertType => ({ outputIndex: '.siem-signals', savedId: 'some-id', timelineId: 'some-timeline-id', + timelineTitle: 'some-timeline-title', meta: { someMeta: 'someField' }, filters: [ { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json index afe9bac9d87fe..79fb136afd52a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json @@ -36,6 +36,9 @@ "timeline_id": { "type": "keyword" }, + "timeline_title": { + "type": "keyword" + }, "max_signals": { "type": "keyword" }, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index 256b341fca656..3d9719a7b248b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -74,6 +74,7 @@ export const createCreateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou updated_at: updatedAt, references, timeline_id: timelineId, + timeline_title: timelineTitle, version, } = payloadRule; const ruleIdOrUuid = ruleId ?? uuid.v4(); @@ -112,6 +113,7 @@ export const createCreateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou outputIndex: finalIndex, savedId, timelineId, + timelineTitle, meta, filters, ruleId: ruleIdOrUuid, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index 476d5b8a49ba2..cf8fb2a28288f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -44,6 +44,7 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = output_index: outputIndex, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -101,6 +102,7 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = outputIndex: finalIndex, savedId, timelineId, + timelineTitle, meta, filters, ruleId: ruleId != null ? ruleId : uuid.v4(), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index b30b6c791522b..180a75bdaaeea 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -50,6 +50,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou output_index: outputIndex, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -82,6 +83,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou outputIndex, savedId, timelineId, + timelineTitle, meta, filters, id, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index ec3d9514fa5db..6db8a8902915a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -38,6 +38,7 @@ export const createUpdateRulesRoute: Hapi.ServerRoute = { output_index: outputIndex, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -77,6 +78,7 @@ export const createUpdateRulesRoute: Hapi.ServerRoute = { outputIndex, savedId, timelineId, + timelineTitle, meta, filters, id, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts index b1f61d11458fe..44d47ad435682 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts @@ -79,6 +79,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -141,6 +142,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -205,6 +207,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -269,6 +272,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -331,6 +335,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -396,6 +401,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -461,6 +467,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -526,6 +533,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -642,6 +650,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', version: 1, }; expect(output).toEqual({ @@ -714,6 +723,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', version: 1, }; expect(output).toEqual(expected); @@ -875,6 +885,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', version: 1, }; expect(output).toEqual(expected); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index b9bf3f8a942fc..714035a423413 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -85,6 +85,7 @@ export const transformAlertToRule = (alert: RuleAlertType): Partial { test('You can omit the query string when filters are present', () => { expect( - addPrepackagedRulesSchema.validate< - Partial & { meta: string }> - >({ + addPrepackagedRulesSchema.validate>({ rule_id: 'rule-1', risk_score: 50, description: 'some description', @@ -1099,7 +1097,7 @@ describe('add prepackaged rules schema', () => { ).toBeFalsy(); }); - test('validates with timeline_id', () => { + test('validates with timeline_id and timeline_title', () => { expect( addPrepackagedRulesSchema.validate>({ rule_id: 'rule-1', @@ -1117,7 +1115,131 @@ describe('add prepackaged rules schema', () => { language: 'kuery', version: 1, timeline_id: 'timeline-id', + timeline_title: 'timeline-title', }).error ).toBeFalsy(); }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: 'timeline-id', + }).error + ).toBeTruthy(); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: 'timeline-id', + timeline_title: null, + }).error + ).toBeTruthy(); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: 'timeline-id', + timeline_title: '', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: '', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts index c993b05cb5f29..49907b4a975e6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts @@ -21,6 +21,7 @@ import { language, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, @@ -63,6 +64,7 @@ export const addPrepackagedRulesSchema = Joi.object({ otherwise: Joi.forbidden(), }), timeline_id, + timeline_title, meta, risk_score: risk_score.required(), max_signals: max_signals.default(DEFAULT_MAX_SIGNALS), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts index 8dc00b66e97a3..87916bea60649 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts @@ -1024,7 +1024,7 @@ describe('create rules schema', () => { test('You can omit the query string when filters are present', () => { expect( - createRulesSchema.validate & { meta: string }>>({ + createRulesSchema.validate>({ rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -1045,7 +1045,7 @@ describe('create rules schema', () => { ).toBeFalsy(); }); - test('timeline_id validates', () => { + test('validates with timeline_id and timeline_title', () => { expect( createRulesSchema.validate>({ rule_id: 'rule-1', @@ -1062,8 +1062,122 @@ describe('create rules schema', () => { references: ['index-1'], query: 'some query', language: 'kuery', - timeline_id: 'some_id', + timeline_id: 'timeline-id', + timeline_title: 'timeline-title', }).error ).toBeFalsy(); }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + }).error + ).toBeTruthy(); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + timeline_title: null, + }).error + ).toBeTruthy(); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + timeline_title: '', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: '', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts index 614451312d04d..df5c1694d6c78 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts @@ -22,6 +22,7 @@ import { output_index, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, @@ -57,6 +58,7 @@ export const createRulesSchema = Joi.object({ otherwise: Joi.forbidden(), }), timeline_id, + timeline_title, meta, risk_score: risk_score.required(), max_signals: max_signals.default(DEFAULT_MAX_SIGNALS), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts index 68d3166c74d6d..c8331bb7820dd 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts @@ -24,6 +24,11 @@ export const language = Joi.string().valid('kuery', 'lucene'); export const output_index = Joi.string(); export const saved_id = Joi.string(); export const timeline_id = Joi.string(); +export const timeline_title = Joi.string().when('timeline_id', { + is: Joi.exist(), + then: Joi.required(), + otherwise: Joi.forbidden(), +}); export const meta = Joi.object(); export const max_signals = Joi.number().greater(0); export const name = Joi.string(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts index 1f00e0a13866a..f713840ab43f9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts @@ -867,7 +867,7 @@ describe('update rules schema', () => { ).toBeTruthy(); }); - test('timeline_id validates', () => { + test('validates with timeline_id and timeline_title', () => { expect( updateRulesSchema.validate>({ id: 'rule-1', @@ -881,7 +881,101 @@ describe('update rules schema', () => { type: 'saved_query', saved_id: 'some id', timeline_id: 'some-id', + timeline_title: 'some-title', }).error ).toBeFalsy(); }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'some-id', + }).error + ).toBeTruthy(); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'timeline-id', + timeline_title: null, + }).error + ).toBeTruthy(); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'some-id', + timeline_title: '', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: '', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts index afd8a5fce4833..9c3188738faea 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts @@ -22,6 +22,7 @@ import { output_index, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, @@ -53,6 +54,7 @@ export const updateRulesSchema = Joi.object({ output_index, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts index 07cf0b0c716cc..d2f76907d7aa3 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts @@ -19,6 +19,7 @@ export const createRules = async ({ language, savedId, timelineId, + timelineTitle, meta, filters, ruleId, @@ -56,6 +57,7 @@ export const createRules = async ({ outputIndex, savedId, timelineId, + timelineTitle, meta, filters, maxSignals, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts index 9acfbf8c43221..9c3be64f71a0d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts @@ -26,6 +26,7 @@ export const installPrepackagedRules = async ( language, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -55,6 +56,7 @@ export const installPrepackagedRules = async ( outputIndex, savedId, timelineId, + timelineTitle, meta, filters, ruleId, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index c9dac82b6eb8f..0fe4b15437af8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -74,6 +74,7 @@ export const updateRules = async ({ outputIndex, savedId, timelineId, + timelineTitle, meta, filters, from, @@ -118,6 +119,7 @@ export const updateRules = async ({ outputIndex, savedId, timelineId, + timelineTitle, meta, filters, index, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json index 2f995029447ff..eb87a14e0c688 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json @@ -7,5 +7,6 @@ "from": "now-6m", "to": "now", "query": "user.name: root or user.name: admin", - "timeline_id": "timeline-id" + "timeline_id": "timeline-id", + "timeline_title": "timeline_title" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json index 60095a0a6a833..46a2feeefd49e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json @@ -78,5 +78,6 @@ "Some plain text string here explaining why this is a valid thing to look out for" ], "timeline_id": "timeline_id", + "timeline_title": "timeline_title", "version": 1 } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json index 2628b69eb064d..16d5d6cc2b36a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json @@ -78,5 +78,6 @@ "Some plain text string here explaining why this is a valid thing to look out for" ], "saved_id": "test-saved-id", - "timeline_id": "test-timeline-id" + "timeline_id": "test-timeline-id", + "timeline_title": "test-timeline-title" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json index 4da285e5b09bf..7fc8de9fe8f9e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json @@ -78,5 +78,6 @@ "Some plain text string here explaining why this is a valid thing to look out for" ], "timeline_id": "other-timeline-id", + "timeline_title": "other-timeline-title", "version": 42 } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json index 8cfa3303f54a6..27dee7dd81463 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json @@ -1,4 +1,5 @@ { "rule_id": "query-rule-id", - "timeline_id": "other-timeline-id" + "timeline_id": "other-timeline-id", + "timeline_title": "other-timeline-title" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts index 5e50b65b51717..ede82a597b238 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -31,6 +31,7 @@ export const sampleRuleAlertParams = ( filters: undefined, savedId: undefined, timelineId: undefined, + timelineTitle: undefined, meta: undefined, threats: undefined, version: 1, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts index 0a3526d32e511..1093ff3a8a462 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts @@ -34,6 +34,7 @@ export const buildRule = ({ false_positives: ruleParams.falsePositives, saved_id: ruleParams.savedId, timeline_id: ruleParams.timelineId, + timeline_title: ruleParams.timelineTitle, meta: ruleParams.meta, max_signals: ruleParams.maxSignals, risk_score: ruleParams.riskScore, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 87d31abbc5371..ab2c1733b04ca 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -42,6 +42,7 @@ export const signalRulesAlertType = ({ outputIndex: schema.nullable(schema.string()), savedId: schema.nullable(schema.string()), timelineId: schema.nullable(schema.string()), + timelineTitle: schema.nullable(schema.string()), meta: schema.nullable(schema.object({}, { allowUnknowns: true })), query: schema.nullable(schema.string()), filters: schema.nullable(schema.arrayOf(schema.object({}, { allowUnknowns: true }))), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index f4a8263da6ba4..ff0f2a8782cd2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -44,6 +44,7 @@ export interface RuleAlertParams { tags: string[]; to: string; timelineId: string | undefined | null; + timelineTitle: string | undefined | null; threats: ThreatParams[] | undefined | null; type: 'query' | 'saved_query'; version: number; @@ -60,6 +61,7 @@ export type RuleAlertParamsRest = Omit< | 'savedId' | 'riskScore' | 'timelineId' + | 'timelineTitle' | 'outputIndex' | 'updatedAt' | 'createdAt' @@ -68,6 +70,7 @@ export type RuleAlertParamsRest = Omit< false_positives: RuleAlertParams['falsePositives']; saved_id: RuleAlertParams['savedId']; timeline_id: RuleAlertParams['timelineId']; + timeline_title: RuleAlertParams['timelineTitle']; max_signals: RuleAlertParams['maxSignals']; risk_score: RuleAlertParams['riskScore']; output_index: RuleAlertParams['outputIndex']; From 0e46b240bbc7daef9e8191e847adb924f7947bab Mon Sep 17 00:00:00 2001 From: Brandon Morelli Date: Wed, 8 Jan 2020 16:44:56 -0800 Subject: [PATCH 30/63] [docs][APM] Add runtime index config documentation (#53907) --- docs/apm/settings.asciidoc | 14 +++++++++++--- docs/apm/troubleshooting.asciidoc | 16 ++++++++++++---- docs/settings/apm-settings.asciidoc | 23 ++++++++++++++++++++--- docs/settings/images/apm-settings.png | Bin 0 -> 299491 bytes 4 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 docs/settings/images/apm-settings.png diff --git a/docs/apm/settings.asciidoc b/docs/apm/settings.asciidoc index 2fc8748f13b09..37122fc9c635d 100644 --- a/docs/apm/settings.asciidoc +++ b/docs/apm/settings.asciidoc @@ -3,8 +3,16 @@ [[apm-settings-in-kibana]] === APM settings in Kibana -You do not need to configure any settings to use APM. It is enabled by default. -If you'd like to change any of the default values, -copy and paste the relevant settings below into your `kibana.yml` configuration file. +You do not need to configure any settings to use the APM app. It is enabled by default. + +[float] +[[apm-indices-settings]] +==== APM Indices + +include::./../settings/apm-settings.asciidoc[tag=apm-indices-settings] + +[float] +[[general-apm-settings]] +==== General APM settings include::./../settings/apm-settings.asciidoc[tag=general-apm-settings] diff --git a/docs/apm/troubleshooting.asciidoc b/docs/apm/troubleshooting.asciidoc index ec0863b09d653..22279b69b70fe 100644 --- a/docs/apm/troubleshooting.asciidoc +++ b/docs/apm/troubleshooting.asciidoc @@ -17,6 +17,7 @@ This section can help with any of the following: There are a number of factors that could be at play here. One important thing to double-check first is your index template. +*Index template* An APM index template must exist for the APM app to work correctly. By default, this index template is created by APM Server on startup. However, this only happens if `setup.template.enabled` is `true` in `apm-server.yml`. @@ -34,14 +35,21 @@ GET /_template/apm-{version} -------------------------------------------------- // CONSOLE +*Using Logstash, Kafka, etc.* If you're not outputting data directly from APM Server to Elasticsearch (perhaps you're using Logstash or Kafka), then the index template will not be set up automatically. Instead, you'll need to -{apm-server-ref}/_manually_loading_template_configuration.html#load-template-manually-alternate[load the template manually]. +{apm-server-ref}/_manually_loading_template_configuration.html[load the template manually]. -Finally, this problem can also occur if you've changed the index name that you write APM data to. -The default index pattern can be found {apm-server-ref}/elasticsearch-output.html#index-option-es[here]. -If you change this setting, you must also configure the `setup.template.name` and `setup.template.pattern` options. +*Using a custom index names* +This problem can also occur if you've customized the index name that you write APM data to. +The default index name that APM writes events to can be found +{apm-server-ref}/elasticsearch-output.html#index-option-es[here]. +If you change the default, you must also configure the `setup.template.name` and `setup.template.pattern` options. See {apm-server-ref}/configuration-template.html[Load the Elasticsearch index template]. +If the Elasticsearch index template has already been successfully loaded to the index, +you can customize the indices that the APM app uses to display data. +Navigate to *APM* > *Settings* > *Indices*, and change all `apm_oss.*Pattern` values to +include the new index pattern. For example: `customIndexName-*`. ==== Unknown route diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index 8d28b55a6502f..a6eeffec51cb0 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -5,9 +5,23 @@ APM settings ++++ -You do not need to configure any settings to use APM. It is enabled by default. -If you'd like to change any of the default values, -copy and paste the relevant settings below into your `kibana.yml` configuration file. +You do not need to configure any settings to use the APM app. It is enabled by default. + +[float] +[[apm-indices-settings-kb]] +==== APM Indices + +// This content is reused in the APM app documentation. +// Any changes made in this file will be seen there as well. +// tag::apm-indices-settings[] + +Index defaults can be changed in Kibana. Navigate to *APM* > *Settings* > *Indices*. +Index settings in the APM app take precedence over those set in `kibana.yml`. + +[role="screenshot"] +image::settings/images/apm-settings.png[APM app settings in Kibana] + +// end::apm-indices-settings[] [float] [[general-apm-settings-kb]] @@ -17,6 +31,9 @@ copy and paste the relevant settings below into your `kibana.yml` configuration // Any changes made in this file will be seen there as well. // tag::general-apm-settings[] +If you'd like to change any of the default values, +copy and paste the relevant settings below into your `kibana.yml` configuration file. + xpack.apm.enabled:: Set to `false` to disabled the APM plugin {kib}. Defaults to `true`. diff --git a/docs/settings/images/apm-settings.png b/docs/settings/images/apm-settings.png new file mode 100644 index 0000000000000000000000000000000000000000..876f135da93564e81886547698db7103fc83f0f2 GIT binary patch literal 299491 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 z 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

BFWe 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 O@3e*WkLBj)ul^74l!HG2 literal 0 HcmV?d00001 From a27c4c4a4e67383e790b0fd4de187735bda69487 Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Thu, 9 Jan 2020 02:55:17 +0200 Subject: [PATCH 31/63] =?UTF-8?q?[Telemetry]=20[Monitoring]=20Only=20retry?= =?UTF-8?q?=20fetching=20usage=20once=20monito=E2=80=A6=20(#54309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix interval and add tests * Update x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js Co-Authored-By: Christiane (Tina) Heiligers Co-authored-by: Christiane (Tina) Heiligers --- .../__tests__/bulk_uploader.js | 54 ++++++++++++++--- .../server/kibana_monitoring/bulk_uploader.js | 58 ++++++++++++------- 2 files changed, 85 insertions(+), 27 deletions(-) diff --git a/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js b/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js index fff0b742925c8..ef7d3f1224fab 100644 --- a/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js +++ b/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js @@ -209,7 +209,7 @@ describe('BulkUploader', () => { }, CHECK_DELAY); }); - it('refetches UsageCollectors if uploading to local cluster was not successful', done => { + it('stops refetching UsageCollectors if uploading to local cluster was not successful', async () => { const usageCollectorFetch = sinon .stub() .returns({ type: 'type_usage_collector_test', result: { testData: 12345 } }); @@ -227,12 +227,52 @@ describe('BulkUploader', () => { uploader._onPayload = async () => ({ took: 0, ignored: true, errors: false }); - uploader.start(collectors); - setTimeout(() => { - uploader.stop(); - expect(usageCollectorFetch.callCount).to.be.greaterThan(1); - done(); - }, CHECK_DELAY); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + + expect(uploader._holdSendingUsage).to.eql(true); + expect(usageCollectorFetch.callCount).to.eql(1); + }); + + it('fetches UsageCollectors once uploading to local cluster is successful again', async () => { + const usageCollectorFetch = sinon + .stub() + .returns({ type: 'type_usage_collector_test', result: { usageData: 12345 } }); + + const statsCollectorFetch = sinon + .stub() + .returns({ type: 'type_stats_collector_test', result: { statsData: 12345 } }); + + const collectors = new MockCollectorSet(server, [ + { + fetch: statsCollectorFetch, + isReady: () => true, + formatForBulkUpload: result => result, + isUsageCollector: false, + }, + { + fetch: usageCollectorFetch, + isReady: () => true, + formatForBulkUpload: result => result, + isUsageCollector: true, + }, + ]); + + const uploader = new BulkUploader({ ...server, interval: FETCH_INTERVAL }); + let bulkIgnored = true; + uploader._onPayload = async () => ({ took: 0, ignored: bulkIgnored, errors: false }); + + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + expect(uploader._holdSendingUsage).to.eql(true); + + bulkIgnored = false; + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + + expect(uploader._holdSendingUsage).to.eql(false); + expect(usageCollectorFetch.callCount).to.eql(2); + expect(statsCollectorFetch.callCount).to.eql(3); }); it('calls UsageCollectors if last reported exceeds during a _usageInterval', done => { diff --git a/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js b/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js index 2d81cb23b6b3b..5e0d8aa4be1fd 100644 --- a/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js +++ b/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js @@ -40,8 +40,14 @@ export class BulkUploader { } this._timer = null; + // Hold sending and fetching usage until monitoring.bulk is successful. This means that we + // send usage data on the second tick. But would save a lot of bandwidth fetching usage on + // every tick when ES is failing or monitoring is disabled. + this._holdSendingUsage = false; this._interval = interval; this._lastFetchUsageTime = null; + // Limit sending and fetching usage to once per day once usage is successfully stored + // into the monitoring indices. this._usageInterval = TELEMETRY_COLLECTION_INTERVAL; this._log = { @@ -65,6 +71,29 @@ export class BulkUploader { }); } + filterCollectorSet(usageCollection) { + const successfulUploadInLastDay = + this._lastFetchUsageTime && this._lastFetchUsageTime + this._usageInterval > Date.now(); + + return usageCollection.getFilteredCollectorSet(c => { + // this is internal bulk upload, so filter out API-only collectors + if (c.ignoreForInternalUploader) { + return false; + } + // Only collect usage data at the same interval as telemetry would (default to once a day) + if (usageCollection.isUsageCollector(c)) { + if (this._holdSendingUsage) { + return false; + } + if (successfulUploadInLastDay) { + return false; + } + } + + return true; + }); + } + /* * Start the interval timer * @param {usageCollection} usageCollection object to use for initial the fetch/upload and fetch/uploading on interval @@ -72,31 +101,15 @@ export class BulkUploader { */ start(usageCollection) { this._log.info('Starting monitoring stats collection'); - const filterCollectorSet = _usageCollection => { - const successfulUploadInLastDay = - this._lastFetchUsageTime && this._lastFetchUsageTime + this._usageInterval > Date.now(); - - return _usageCollection.getFilteredCollectorSet(c => { - // this is internal bulk upload, so filter out API-only collectors - if (c.ignoreForInternalUploader) { - return false; - } - // Only collect usage data at the same interval as telemetry would (default to once a day) - if (successfulUploadInLastDay && _usageCollection.isUsageCollector(c)) { - return false; - } - return true; - }); - }; if (this._timer) { clearInterval(this._timer); } else { - this._fetchAndUpload(filterCollectorSet(usageCollection)); // initial fetch + this._fetchAndUpload(this.filterCollectorSet(usageCollection)); // initial fetch } this._timer = setInterval(() => { - this._fetchAndUpload(filterCollectorSet(usageCollection)); + this._fetchAndUpload(this.filterCollectorSet(usageCollection)); }, this._interval); } @@ -146,12 +159,17 @@ export class BulkUploader { const sendSuccessful = !result.ignored && !result.errors; if (!sendSuccessful && hasUsageCollectors) { this._lastFetchUsageTime = null; + this._holdSendingUsage = true; this._log.debug( 'Resetting lastFetchWithUsage because uploading to the cluster was not successful.' ); } - if (sendSuccessful && hasUsageCollectors) { - this._lastFetchUsageTime = Date.now(); + + if (sendSuccessful) { + this._holdSendingUsage = false; + if (hasUsageCollectors) { + this._lastFetchUsageTime = Date.now(); + } } this._log.debug(`Uploaded bulk stats payload to the local cluster`); } catch (err) { From d5939c4af8721d5696353e664848858d13d8916c Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Thu, 9 Jan 2020 03:41:24 +0100 Subject: [PATCH 32/63] [Discover] fix histogram min interval (#53979) - Fixes issues involving min intervals for leap years and DST --- .../np_ready/angular/directives/histogram.tsx | 57 +++++++++++- .../np_ready/public/legacy/build_pipeline.ts | 2 + .../point_series/__tests__/_init_x_axis.js | 4 + .../agg_response/point_series/_init_x_axis.js | 18 +++- .../apps/discover/_discover_histogram.js | 91 +++++++++++++++++++ test/functional/apps/discover/index.js | 1 + .../apps/management/_handle_alias.js | 2 +- test/functional/page_objects/discover_page.js | 15 ++- 8 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 test/functional/apps/discover/_discover_histogram.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx index c2f716ff6c45a..b83ac5b4a7795 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx @@ -19,6 +19,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer } from '@elastic/eui'; import moment from 'moment-timezone'; +import { unitOfTime } from 'moment'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; import lightEuiTheme from '@elastic/eui/dist/eui_theme_light.json'; @@ -53,6 +54,58 @@ interface DiscoverHistogramState { chartsTheme: EuiChartThemeType['theme']; } +function findIntervalFromDuration( + dateValue: number, + esValue: number, + esUnit: unitOfTime.Base, + timeZone: string +) { + const date = moment.tz(dateValue, timeZone); + const startOfDate = moment.tz(date, timeZone).startOf(esUnit); + const endOfDate = moment + .tz(date, timeZone) + .startOf(esUnit) + .add(esValue, esUnit); + return endOfDate.valueOf() - startOfDate.valueOf(); +} + +function getIntervalInMs( + value: number, + esValue: number, + esUnit: unitOfTime.Base, + timeZone: string +): number { + switch (esUnit) { + case 's': + return 1000 * esValue; + case 'ms': + return 1 * esValue; + default: + return findIntervalFromDuration(value, esValue, esUnit, timeZone); + } +} + +export function findMinInterval( + xValues: number[], + esValue: number, + esUnit: string, + timeZone: string +): number { + return xValues.reduce((minInterval, currentXvalue, index) => { + let currentDiff = minInterval; + if (index > 0) { + currentDiff = Math.abs(xValues[index - 1] - currentXvalue); + } + const singleUnitInterval = getIntervalInMs( + currentXvalue, + esValue, + esUnit as unitOfTime.Base, + timeZone + ); + return Math.min(minInterval, singleUnitInterval, currentDiff); + }, Number.MAX_SAFE_INTEGER); +} + export class DiscoverHistogram extends Component { public static propTypes = { chartData: PropTypes.object, @@ -154,7 +207,7 @@ export class DiscoverHistogram extends Component { + await esArchiver.unload('long_window_logstash'); + await esArchiver.unload('visualize'); + await esArchiver.unload('discover'); + }); + + it('should visualize monthly data with different day intervals', async () => { + //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 + const fromTime = '2017-11-01 00:00:00.000'; + const toTime = '2018-03-21 00:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.setChartInterval('Monthly'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + expect(chartCanvasExist).to.be(true); + }); + it('should visualize weekly data with within DST changes', async () => { + //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 + const fromTime = '2018-03-01 00:00:00.000'; + const toTime = '2018-05-01 00:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.setChartInterval('Weekly'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + expect(chartCanvasExist).to.be(true); + }); + it('should visualize monthly data with different years Scaled to 30d', async () => { + //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 + const fromTime = '2010-01-01 00:00:00.000'; + const toTime = '2018-03-21 00:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.setChartInterval('Daily'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + expect(chartCanvasExist).to.be(true); + }); + }); +} diff --git a/test/functional/apps/discover/index.js b/test/functional/apps/discover/index.js index e10e772e93ab1..64a5a61335365 100644 --- a/test/functional/apps/discover/index.js +++ b/test/functional/apps/discover/index.js @@ -34,6 +34,7 @@ export default function({ getService, loadTestFile }) { loadTestFile(require.resolve('./_saved_queries')); loadTestFile(require.resolve('./_discover')); + loadTestFile(require.resolve('./_discover_histogram')); loadTestFile(require.resolve('./_filter_editor')); loadTestFile(require.resolve('./_errors')); loadTestFile(require.resolve('./_field_data')); diff --git a/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.js index 06406bddeb009..3d9368f8d4680 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -74,7 +74,7 @@ export default function({ getService, getPageObjects }) { const toTime = 'Nov 19, 2016 @ 05:00:00.000'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.selectIndexPattern('alias2'); + await PageObjects.discover.selectIndexPattern('alias2*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await retry.try(async function() { diff --git a/test/functional/page_objects/discover_page.js b/test/functional/page_objects/discover_page.js index 7029fbf9e1350..3ba0f217813f2 100644 --- a/test/functional/page_objects/discover_page.js +++ b/test/functional/page_objects/discover_page.js @@ -117,8 +117,16 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { await testSubjects.click('discoverOpenButton'); } + async getChartCanvas() { + return await find.byCssSelector('.echChart canvas:last-of-type'); + } + + async chartCanvasExist() { + return await find.existsByCssSelector('.echChart canvas:last-of-type'); + } + async clickHistogramBar() { - const el = await find.byCssSelector('.echChart canvas:last-of-type'); + const el = await this.getChartCanvas(); await browser .getActions() @@ -128,7 +136,8 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { } async brushHistogram() { - const el = await find.byCssSelector('.echChart canvas:last-of-type'); + const el = await this.getChartCanvas(); + await browser.dragAndDrop( { location: el, offset: { x: 200, y: 20 } }, { location: el, offset: { x: 400, y: 30 } } @@ -279,7 +288,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { async selectIndexPattern(indexPattern) { await testSubjects.click('indexPattern-switch-link'); await find.clickByCssSelector( - `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}*"]` + `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}"]` ); await PageObjects.header.waitUntilLoadingHasFinished(); } From ecddfd8842e48f9d559c1b4a7d5c4a42fa4259e4 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Thu, 9 Jan 2020 11:00:30 +0300 Subject: [PATCH 33/63] [Vis: Default editor] Reactify the timelion editor (#52990) * Reactify timelion editor * Change translation ids * Add @types/pegjs into renovate.json5 * Add validation, add hover suggestions * Style fixes * Change plugin setup, use kibana context * Change plugin start * Mock services * Fix other comments * Build renovate config * Fix some classnames and SASS file structure Co-authored-by: Elastic Machine Co-authored-by: Caroline Horn <549577+cchaos@users.noreply.github.com> --- package.json | 1 + renovate.json5 | 8 + .../core_plugins/timelion/common/types.ts | 46 +++ .../timelion/public/components/_index.scss | 1 + .../_timelion_expression_input.scss | 18 ++ .../timelion/public/components/index.ts | 21 ++ .../components/timelion_expression_input.tsx | 146 +++++++++ .../timelion_expression_input_helpers.ts | 287 ++++++++++++++++++ .../public/components/timelion_interval.tsx | 144 +++++++++ .../timelion_expression_input_helpers.js | 13 +- .../directives/timelion_expression_input.js | 6 +- .../core_plugins/timelion/public/index.scss | 1 + .../core_plugins/timelion/public/legacy.ts | 2 +- .../public/panels/timechart/schema.ts | 1 + .../core_plugins/timelion/public/plugin.ts | 7 +- .../arg_value_suggestions.ts} | 81 +++-- .../public/services/plugin_services.ts | 30 ++ .../timelion/public/timelion_vis_fn.ts | 6 +- .../timelion/public/vis/_index.scss | 1 + .../timelion/public/vis/_timelion_editor.scss | 15 + .../public/vis/{index.ts => index.tsx} | 13 +- .../timelion/public/vis/timelion_options.tsx | 48 +++ .../public/vis/timelion_vis_params.html | 27 -- .../server/lib/classes/timelion_function.d.ts | 17 +- .../core_plugins/timelion/server/types.ts | 9 +- .../public/code_editor/code_editor.tsx | 14 + yarn.lock | 5 + 27 files changed, 875 insertions(+), 93 deletions(-) create mode 100644 src/legacy/core_plugins/timelion/common/types.ts create mode 100644 src/legacy/core_plugins/timelion/public/components/_index.scss create mode 100644 src/legacy/core_plugins/timelion/public/components/_timelion_expression_input.scss create mode 100644 src/legacy/core_plugins/timelion/public/components/index.ts create mode 100644 src/legacy/core_plugins/timelion/public/components/timelion_expression_input.tsx create mode 100644 src/legacy/core_plugins/timelion/public/components/timelion_expression_input_helpers.ts create mode 100644 src/legacy/core_plugins/timelion/public/components/timelion_interval.tsx rename src/legacy/core_plugins/timelion/public/{directives/timelion_expression_suggestions/arg_value_suggestions.js => services/arg_value_suggestions.ts} (72%) create mode 100644 src/legacy/core_plugins/timelion/public/services/plugin_services.ts create mode 100644 src/legacy/core_plugins/timelion/public/vis/_timelion_editor.scss rename src/legacy/core_plugins/timelion/public/vis/{index.ts => index.tsx} (80%) create mode 100644 src/legacy/core_plugins/timelion/public/vis/timelion_options.tsx delete mode 100644 src/legacy/core_plugins/timelion/public/vis/timelion_vis_params.html diff --git a/package.json b/package.json index db5764e6e91ba..0dbed9e432e99 100644 --- a/package.json +++ b/package.json @@ -341,6 +341,7 @@ "@types/mustache": "^0.8.31", "@types/node": "^10.12.27", "@types/opn": "^5.1.0", + "@types/pegjs": "^0.10.1", "@types/pngjs": "^3.3.2", "@types/podium": "^1.0.0", "@types/prop-types": "^15.5.3", diff --git a/renovate.json5 b/renovate.json5 index f069e961c0f2b..a5983283a9e85 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -673,6 +673,14 @@ '@types/parse-link-header', ], }, + { + groupSlug: 'pegjs', + groupName: 'pegjs related packages', + packageNames: [ + 'pegjs', + '@types/pegjs', + ], + }, { groupSlug: 'pngjs', groupName: 'pngjs related packages', diff --git a/src/legacy/core_plugins/timelion/common/types.ts b/src/legacy/core_plugins/timelion/common/types.ts new file mode 100644 index 0000000000000..f7084948a14f7 --- /dev/null +++ b/src/legacy/core_plugins/timelion/common/types.ts @@ -0,0 +1,46 @@ +/* + * 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. + */ + +type TimelionFunctionArgsTypes = 'seriesList' | 'number' | 'string' | 'boolean' | 'null'; + +interface TimelionFunctionArgsSuggestion { + name: string; + help: string; +} + +export interface TimelionFunctionArgs { + name: string; + help?: string; + multi?: boolean; + types: TimelionFunctionArgsTypes[]; + suggestions?: TimelionFunctionArgsSuggestion[]; +} + +export interface ITimelionFunction { + aliases: string[]; + args: TimelionFunctionArgs[]; + name: string; + help: string; + chainable: boolean; + extended: boolean; + isAlias: boolean; + argsByName: { + [key: string]: TimelionFunctionArgs[]; + }; +} diff --git a/src/legacy/core_plugins/timelion/public/components/_index.scss b/src/legacy/core_plugins/timelion/public/components/_index.scss new file mode 100644 index 0000000000000..f2458a367e176 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/_index.scss @@ -0,0 +1 @@ +@import './timelion_expression_input'; diff --git a/src/legacy/core_plugins/timelion/public/components/_timelion_expression_input.scss b/src/legacy/core_plugins/timelion/public/components/_timelion_expression_input.scss new file mode 100644 index 0000000000000..b1c0b5514ff7a --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/_timelion_expression_input.scss @@ -0,0 +1,18 @@ +.timExpressionInput { + flex: 1 1 auto; + display: flex; + flex-direction: column; + margin-top: $euiSize; +} + +.timExpressionInput__editor { + height: 100%; + padding-top: $euiSizeS; +} + +@include euiBreakpoint('xs', 's', 'm') { + .timExpressionInput__editor { + height: $euiSize * 15; + max-height: $euiSize * 15; + } +} diff --git a/src/legacy/core_plugins/timelion/public/components/index.ts b/src/legacy/core_plugins/timelion/public/components/index.ts new file mode 100644 index 0000000000000..8d7d32a3ba262 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/index.ts @@ -0,0 +1,21 @@ +/* + * 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 './timelion_expression_input'; +export * from './timelion_interval'; diff --git a/src/legacy/core_plugins/timelion/public/components/timelion_expression_input.tsx b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input.tsx new file mode 100644 index 0000000000000..c695d09ca822b --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input.tsx @@ -0,0 +1,146 @@ +/* + * 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, useRef, useMemo } from 'react'; +import { EuiFormLabel } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; + +import { CodeEditor, useKibana } from '../../../../../plugins/kibana_react/public'; +import { suggest, getSuggestion } from './timelion_expression_input_helpers'; +import { ITimelionFunction, TimelionFunctionArgs } from '../../common/types'; +import { getArgValueSuggestions } from '../services/arg_value_suggestions'; + +const LANGUAGE_ID = 'timelion_expression'; +monacoEditor.languages.register({ id: LANGUAGE_ID }); + +interface TimelionExpressionInputProps { + value: string; + setValue(value: string): void; +} + +function TimelionExpressionInput({ value, setValue }: TimelionExpressionInputProps) { + const functionList = useRef([]); + const kibana = useKibana(); + const argValueSuggestions = useMemo(getArgValueSuggestions, []); + + const provideCompletionItems = useCallback( + async (model: monacoEditor.editor.ITextModel, position: monacoEditor.Position) => { + const text = model.getValue(); + const wordUntil = model.getWordUntilPosition(position); + const wordRange = new monacoEditor.Range( + position.lineNumber, + wordUntil.startColumn, + position.lineNumber, + wordUntil.endColumn + ); + + const suggestions = await suggest( + text, + functionList.current, + // it's important to offset the cursor position on 1 point left + // because of PEG parser starts the line with 0, but monaco with 1 + position.column - 1, + argValueSuggestions + ); + + return { + suggestions: suggestions + ? suggestions.list.map((s: ITimelionFunction | TimelionFunctionArgs) => + getSuggestion(s, suggestions.type, wordRange) + ) + : [], + }; + }, + [argValueSuggestions] + ); + + const provideHover = useCallback( + async (model: monacoEditor.editor.ITextModel, position: monacoEditor.Position) => { + const suggestions = await suggest( + model.getValue(), + functionList.current, + // it's important to offset the cursor position on 1 point left + // because of PEG parser starts the line with 0, but monaco with 1 + position.column - 1, + argValueSuggestions + ); + + return { + contents: suggestions + ? suggestions.list.map((s: ITimelionFunction | TimelionFunctionArgs) => ({ + value: s.help, + })) + : [], + }; + }, + [argValueSuggestions] + ); + + useEffect(() => { + if (kibana.services.http) { + kibana.services.http.get('../api/timelion/functions').then(data => { + functionList.current = data; + }); + } + }, [kibana.services.http]); + + return ( +

+ + + +
+ +
+
+ ); +} + +export { TimelionExpressionInput }; diff --git a/src/legacy/core_plugins/timelion/public/components/timelion_expression_input_helpers.ts b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input_helpers.ts new file mode 100644 index 0000000000000..fc90c276eeca2 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input_helpers.ts @@ -0,0 +1,287 @@ +/* + * 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 { get, startsWith } from 'lodash'; +import PEG from 'pegjs'; +import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; + +// @ts-ignore +import grammar from 'raw-loader!../chain.peg'; + +import { i18n } from '@kbn/i18n'; +import { ITimelionFunction, TimelionFunctionArgs } from '../../common/types'; +import { ArgValueSuggestions, FunctionArg, Location } from '../services/arg_value_suggestions'; + +const Parser = PEG.generate(grammar); + +export enum SUGGESTION_TYPE { + ARGUMENTS = 'arguments', + ARGUMENT_VALUE = 'argument_value', + FUNCTIONS = 'functions', +} + +function inLocation(cursorPosition: number, location: Location) { + return cursorPosition >= location.min && cursorPosition <= location.max; +} + +function getArgumentsHelp( + functionHelp: ITimelionFunction | undefined, + functionArgs: FunctionArg[] = [] +) { + if (!functionHelp) { + return []; + } + + // Do not provide 'inputSeries' as argument suggestion for chainable functions + const argsHelp = functionHelp.chainable ? functionHelp.args.slice(1) : functionHelp.args.slice(0); + + // ignore arguments that are already provided in function declaration + const functionArgNames = functionArgs.map(arg => arg.name); + return argsHelp.filter(arg => !functionArgNames.includes(arg.name)); +} + +async function extractSuggestionsFromParsedResult( + result: ReturnType, + cursorPosition: number, + functionList: ITimelionFunction[], + argValueSuggestions: ArgValueSuggestions +) { + const activeFunc = result.functions.find(({ location }: { location: Location }) => + inLocation(cursorPosition, location) + ); + + if (!activeFunc) { + return; + } + + const functionHelp = functionList.find(({ name }) => name === activeFunc.function); + + if (!functionHelp) { + return; + } + + // return function suggestion when cursor is outside of parentheses + // location range includes '.', function name, and '('. + const openParen = activeFunc.location.min + activeFunc.function.length + 2; + if (cursorPosition < openParen) { + return { list: [functionHelp], type: SUGGESTION_TYPE.FUNCTIONS }; + } + + // return argument value suggestions when cursor is inside argument value + const activeArg = activeFunc.arguments.find((argument: FunctionArg) => { + return inLocation(cursorPosition, argument.location); + }); + if ( + activeArg && + activeArg.type === 'namedArg' && + inLocation(cursorPosition, activeArg.value.location) + ) { + const { function: functionName, arguments: functionArgs } = activeFunc; + + const { + name: argName, + value: { text: partialInput }, + } = activeArg; + + let valueSuggestions; + if (argValueSuggestions.hasDynamicSuggestionsForArgument(functionName, argName)) { + valueSuggestions = await argValueSuggestions.getDynamicSuggestionsForArgument( + functionName, + argName, + functionArgs, + partialInput + ); + } else { + const { suggestions: staticSuggestions } = + functionHelp.args.find(arg => arg.name === activeArg.name) || {}; + valueSuggestions = argValueSuggestions.getStaticSuggestionsForInput( + partialInput, + staticSuggestions + ); + } + return { + list: valueSuggestions, + type: SUGGESTION_TYPE.ARGUMENT_VALUE, + }; + } + + // return argument suggestions + const argsHelp = getArgumentsHelp(functionHelp, activeFunc.arguments); + const argumentSuggestions = argsHelp.filter(arg => { + if (get(activeArg, 'type') === 'namedArg') { + return startsWith(arg.name, activeArg.name); + } else if (activeArg) { + return startsWith(arg.name, activeArg.text); + } + return true; + }); + return { list: argumentSuggestions, type: SUGGESTION_TYPE.ARGUMENTS }; +} + +export async function suggest( + expression: string, + functionList: ITimelionFunction[], + cursorPosition: number, + argValueSuggestions: ArgValueSuggestions +) { + try { + const result = await Parser.parse(expression); + + return await extractSuggestionsFromParsedResult( + result, + cursorPosition, + functionList, + argValueSuggestions + ); + } catch (err) { + let message: any; + try { + // The grammar will throw an error containing a message if the expression is formatted + // correctly and is prepared to accept suggestions. If the expression is not formatted + // correctly the grammar will just throw a regular PEG SyntaxError, and this JSON.parse + // attempt will throw an error. + message = JSON.parse(err.message); + } catch (e) { + // The expression isn't correctly formatted, so JSON.parse threw an error. + return; + } + + switch (message.type) { + case 'incompleteFunction': { + let list; + if (message.function) { + // The user has start typing a function name, so we'll filter the list down to only + // possible matches. + list = functionList.filter(func => startsWith(func.name, message.function)); + } else { + // The user hasn't typed anything yet, so we'll just return the entire list. + list = functionList; + } + return { list, type: SUGGESTION_TYPE.FUNCTIONS }; + } + case 'incompleteArgument': { + const { currentFunction: functionName, currentArgs: functionArgs } = message; + const functionHelp = functionList.find(func => func.name === functionName); + return { + list: getArgumentsHelp(functionHelp, functionArgs), + type: SUGGESTION_TYPE.ARGUMENTS, + }; + } + case 'incompleteArgumentValue': { + const { name: argName, currentFunction: functionName, currentArgs: functionArgs } = message; + let valueSuggestions = []; + if (argValueSuggestions.hasDynamicSuggestionsForArgument(functionName, argName)) { + valueSuggestions = await argValueSuggestions.getDynamicSuggestionsForArgument( + functionName, + argName, + functionArgs + ); + } else { + const functionHelp = functionList.find(func => func.name === functionName); + if (functionHelp) { + const argHelp = functionHelp.args.find(arg => arg.name === argName); + if (argHelp && argHelp.suggestions) { + valueSuggestions = argHelp.suggestions; + } + } + } + return { + list: valueSuggestions, + type: SUGGESTION_TYPE.ARGUMENT_VALUE, + }; + } + } + } +} + +export function getSuggestion( + suggestion: ITimelionFunction | TimelionFunctionArgs, + type: SUGGESTION_TYPE, + range: monacoEditor.Range +): monacoEditor.languages.CompletionItem { + let kind: monacoEditor.languages.CompletionItemKind = + monacoEditor.languages.CompletionItemKind.Method; + let insertText: string = suggestion.name; + let insertTextRules: monacoEditor.languages.CompletionItem['insertTextRules']; + let detail: string = ''; + let command: monacoEditor.languages.CompletionItem['command']; + + switch (type) { + case SUGGESTION_TYPE.ARGUMENTS: + command = { + title: 'Trigger Suggestion Dialog', + id: 'editor.action.triggerSuggest', + }; + kind = monacoEditor.languages.CompletionItemKind.Property; + insertText = `${insertText}=`; + detail = `${i18n.translate( + 'timelion.expressionSuggestions.argument.description.acceptsText', + { + defaultMessage: 'Accepts', + } + )}: ${(suggestion as TimelionFunctionArgs).types}`; + + break; + case SUGGESTION_TYPE.FUNCTIONS: + command = { + title: 'Trigger Suggestion Dialog', + id: 'editor.action.triggerSuggest', + }; + kind = monacoEditor.languages.CompletionItemKind.Function; + insertText = `${insertText}($0)`; + insertTextRules = monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet; + detail = `(${ + (suggestion as ITimelionFunction).chainable + ? i18n.translate('timelion.expressionSuggestions.func.description.chainableHelpText', { + defaultMessage: 'Chainable', + }) + : i18n.translate('timelion.expressionSuggestions.func.description.dataSourceHelpText', { + defaultMessage: 'Data source', + }) + })`; + + break; + case SUGGESTION_TYPE.ARGUMENT_VALUE: + const param = suggestion.name.split(':'); + + if (param.length === 1 || param[1]) { + insertText = `${param.length === 1 ? insertText : param[1]},`; + } + + command = { + title: 'Trigger Suggestion Dialog', + id: 'editor.action.triggerSuggest', + }; + kind = monacoEditor.languages.CompletionItemKind.Property; + detail = suggestion.help || ''; + + break; + } + + return { + detail, + insertText, + insertTextRules, + kind, + label: suggestion.name, + documentation: suggestion.help, + command, + range, + }; +} diff --git a/src/legacy/core_plugins/timelion/public/components/timelion_interval.tsx b/src/legacy/core_plugins/timelion/public/components/timelion_interval.tsx new file mode 100644 index 0000000000000..6294e51e54788 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/timelion_interval.tsx @@ -0,0 +1,144 @@ +/* + * 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, { useMemo, useCallback } from 'react'; +import { EuiFormRow, EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { useValidation } from 'ui/vis/editors/default/controls/agg_utils'; +import { isValidEsInterval } from '../../../../core_plugins/data/common'; + +const intervalOptions = [ + { + label: i18n.translate('timelion.vis.interval.auto', { + defaultMessage: 'Auto', + }), + value: 'auto', + }, + { + label: i18n.translate('timelion.vis.interval.second', { + defaultMessage: '1 second', + }), + value: '1s', + }, + { + label: i18n.translate('timelion.vis.interval.minute', { + defaultMessage: '1 minute', + }), + value: '1m', + }, + { + label: i18n.translate('timelion.vis.interval.hour', { + defaultMessage: '1 hour', + }), + value: '1h', + }, + { + label: i18n.translate('timelion.vis.interval.day', { + defaultMessage: '1 day', + }), + value: '1d', + }, + { + label: i18n.translate('timelion.vis.interval.week', { + defaultMessage: '1 week', + }), + value: '1w', + }, + { + label: i18n.translate('timelion.vis.interval.month', { + defaultMessage: '1 month', + }), + value: '1M', + }, + { + label: i18n.translate('timelion.vis.interval.year', { + defaultMessage: '1 year', + }), + value: '1y', + }, +]; + +interface TimelionIntervalProps { + value: string; + setValue(value: string): void; + setValidity(valid: boolean): void; +} + +function TimelionInterval({ value, setValue, setValidity }: TimelionIntervalProps) { + const onCustomInterval = useCallback( + (customValue: string) => { + setValue(customValue.trim()); + }, + [setValue] + ); + + const onChange = useCallback( + (opts: Array>) => { + setValue((opts[0] && opts[0].value) || ''); + }, + [setValue] + ); + + const selectedOptions = useMemo( + () => [intervalOptions.find(op => op.value === value) || { label: value, value }], + [value] + ); + + const isValid = intervalOptions.some(int => int.value === value) || isValidEsInterval(value); + + useValidation(setValidity, isValid); + + return ( + + + + ); +} + +export { TimelionInterval }; diff --git a/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js b/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js index b90f5932b5b09..231330b898edb 100644 --- a/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js +++ b/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js @@ -21,9 +21,15 @@ import expect from '@kbn/expect'; import PEG from 'pegjs'; import grammar from 'raw-loader!../../chain.peg'; import { SUGGESTION_TYPE, suggest } from '../timelion_expression_input_helpers'; -import { ArgValueSuggestionsProvider } from '../timelion_expression_suggestions/arg_value_suggestions'; +import { getArgValueSuggestions } from '../../services/arg_value_suggestions'; +import { setIndexPatterns, setSavedObjectsClient } from '../../services/plugin_services'; describe('Timelion expression suggestions', () => { + setIndexPatterns({}); + setSavedObjectsClient({}); + + const argValueSuggestions = getArgValueSuggestions(); + describe('getSuggestions', () => { const func1 = { name: 'func1', @@ -44,11 +50,6 @@ describe('Timelion expression suggestions', () => { }; const functionList = [func1, myFunc2]; let Parser; - const privateStub = () => { - return {}; - }; - const indexPatternsStub = {}; - const argValueSuggestions = ArgValueSuggestionsProvider(privateStub, indexPatternsStub); // eslint-disable-line new-cap beforeEach(function() { Parser = PEG.generate(grammar); }); diff --git a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js b/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js index 137dd6b82046d..449c0489fea25 100644 --- a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js +++ b/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js @@ -52,11 +52,11 @@ import { insertAtLocation, } from './timelion_expression_input_helpers'; import { comboBoxKeyCodes } from '@elastic/eui'; -import { ArgValueSuggestionsProvider } from './timelion_expression_suggestions/arg_value_suggestions'; +import { getArgValueSuggestions } from '../services/arg_value_suggestions'; const Parser = PEG.generate(grammar); -export function TimelionExpInput($http, $timeout, Private) { +export function TimelionExpInput($http, $timeout) { return { restrict: 'E', scope: { @@ -68,7 +68,7 @@ export function TimelionExpInput($http, $timeout, Private) { replace: true, template: timelionExpressionInputTemplate, link: function(scope, elem) { - const argValueSuggestions = Private(ArgValueSuggestionsProvider); + const argValueSuggestions = getArgValueSuggestions(); const expressionInput = elem.find('[data-expression-input]'); const functionReference = {}; let suggestibleFunctionLocation = {}; diff --git a/src/legacy/core_plugins/timelion/public/index.scss b/src/legacy/core_plugins/timelion/public/index.scss index f6123f4052156..7ccc6c300bc40 100644 --- a/src/legacy/core_plugins/timelion/public/index.scss +++ b/src/legacy/core_plugins/timelion/public/index.scss @@ -11,5 +11,6 @@ // timChart__legend-isLoading @import './app'; +@import './components/index'; @import './directives/index'; @import './vis/index'; diff --git a/src/legacy/core_plugins/timelion/public/legacy.ts b/src/legacy/core_plugins/timelion/public/legacy.ts index d989a68d40eeb..1cf6bb65cdc02 100644 --- a/src/legacy/core_plugins/timelion/public/legacy.ts +++ b/src/legacy/core_plugins/timelion/public/legacy.ts @@ -37,4 +37,4 @@ const setupPlugins: Readonly = { const pluginInstance = plugin({} as PluginInitializerContext); export const setup = pluginInstance.setup(npSetup.core, setupPlugins); -export const start = pluginInstance.start(npStart.core); +export const start = pluginInstance.start(npStart.core, npStart.plugins); diff --git a/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts b/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts index 04b27c4020ce3..0bbda4bf3646f 100644 --- a/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts +++ b/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts @@ -35,6 +35,7 @@ const DEBOUNCE_DELAY = 50; export function timechartFn(dependencies: TimelionVisualizationDependencies) { const { $rootScope, $compile, uiSettings } = dependencies; + return function() { return { help: 'Draw a timeseries chart', diff --git a/src/legacy/core_plugins/timelion/public/plugin.ts b/src/legacy/core_plugins/timelion/public/plugin.ts index ba8c25c20abea..42f0ee3ad4725 100644 --- a/src/legacy/core_plugins/timelion/public/plugin.ts +++ b/src/legacy/core_plugins/timelion/public/plugin.ts @@ -26,12 +26,14 @@ import { } from 'kibana/public'; import { Plugin as ExpressionsPlugin } from 'src/plugins/expressions/public'; import { DataPublicPluginSetup, TimefilterContract } from 'src/plugins/data/public'; +import { PluginsStart } from 'ui/new_platform/new_platform'; import { VisualizationsSetup } from '../../visualizations/public/np_ready/public'; import { getTimelionVisualizationConfig } from './timelion_vis_fn'; import { getTimelionVisualization } from './vis'; import { getTimeChart } from './panels/timechart/timechart'; import { Panel } from './panels/panel'; import { LegacyDependenciesPlugin, LegacyDependenciesPluginSetup } from './shim'; +import { setIndexPatterns, setSavedObjectsClient } from './services/plugin_services'; /** @internal */ export interface TimelionVisualizationDependencies extends LegacyDependenciesPluginSetup { @@ -85,12 +87,15 @@ export class TimelionPlugin implements Plugin, void> { dependencies.timelionPanels.set(timeChartPanel.name, timeChartPanel); } - public start(core: CoreStart) { + public start(core: CoreStart, plugins: PluginsStart) { const timelionUiEnabled = core.injectedMetadata.getInjectedVar('timelionUiEnabled'); if (timelionUiEnabled === false) { core.chrome.navLinks.update('timelion', { hidden: true }); } + + setIndexPatterns(plugins.data.indexPatterns); + setSavedObjectsClient(core.savedObjects.client); } public stop(): void {} diff --git a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js b/src/legacy/core_plugins/timelion/public/services/arg_value_suggestions.ts similarity index 72% rename from src/legacy/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js rename to src/legacy/core_plugins/timelion/public/services/arg_value_suggestions.ts index e698a69401a37..8d133de51f6d9 100644 --- a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js +++ b/src/legacy/core_plugins/timelion/public/services/arg_value_suggestions.ts @@ -17,33 +17,51 @@ * under the License. */ -import _ from 'lodash'; -import { npStart } from 'ui/new_platform'; +import { get } from 'lodash'; +import { TimelionFunctionArgs } from '../../common/types'; +import { getIndexPatterns, getSavedObjectsClient } from './plugin_services'; -export function ArgValueSuggestionsProvider() { - const { indexPatterns } = npStart.plugins.data; - const { client: savedObjectsClient } = npStart.core.savedObjects; +export interface Location { + min: number; + max: number; +} - async function getIndexPattern(functionArgs) { - const indexPatternArg = functionArgs.find(argument => { - return argument.name === 'index'; - }); +export interface FunctionArg { + function: string; + location: Location; + name: string; + text: string; + type: string; + value: { + location: Location; + text: string; + type: string; + value: string; + }; +} + +export function getArgValueSuggestions() { + const indexPatterns = getIndexPatterns(); + const savedObjectsClient = getSavedObjectsClient(); + + async function getIndexPattern(functionArgs: FunctionArg[]) { + const indexPatternArg = functionArgs.find(({ name }) => name === 'index'); if (!indexPatternArg) { // index argument not provided return; } - const indexPatternTitle = _.get(indexPatternArg, 'value.text'); + const indexPatternTitle = get(indexPatternArg, 'value.text'); - const resp = await savedObjectsClient.find({ + const { savedObjects } = await savedObjectsClient.find({ type: 'index-pattern', fields: ['title'], search: `"${indexPatternTitle}"`, - search_fields: ['title'], + searchFields: ['title'], perPage: 10, }); - const indexPatternSavedObject = resp.savedObjects.find(savedObject => { - return savedObject.attributes.title === indexPatternTitle; - }); + const indexPatternSavedObject = savedObjects.find( + ({ attributes }) => attributes.title === indexPatternTitle + ); if (!indexPatternSavedObject) { // index argument does not match an index pattern return; @@ -52,7 +70,7 @@ export function ArgValueSuggestionsProvider() { return await indexPatterns.get(indexPatternSavedObject.id); } - function containsFieldName(partial, field) { + function containsFieldName(partial: string, field: { name: string }) { if (!partial) { return true; } @@ -63,13 +81,13 @@ export function ArgValueSuggestionsProvider() { // Could not put with function definition since functions are defined on server const customHandlers = { es: { - index: async function(partial) { + async index(partial: string) { const search = partial ? `${partial}*` : '*'; const resp = await savedObjectsClient.find({ type: 'index-pattern', fields: ['title', 'type'], search: `${search}`, - search_fields: ['title'], + searchFields: ['title'], perPage: 25, }); return resp.savedObjects @@ -78,7 +96,7 @@ export function ArgValueSuggestionsProvider() { return { name: savedObject.attributes.title }; }); }, - metric: async function(partial, functionArgs) { + async metric(partial: string, functionArgs: FunctionArg[]) { if (!partial || !partial.includes(':')) { return [ { name: 'avg:' }, @@ -109,7 +127,7 @@ export function ArgValueSuggestionsProvider() { return { name: `${valueSplit[0]}:${field.name}`, help: field.type }; }); }, - split: async function(partial, functionArgs) { + async split(partial: string, functionArgs: FunctionArg[]) { const indexPattern = await getIndexPattern(functionArgs); if (!indexPattern) { return []; @@ -127,7 +145,7 @@ export function ArgValueSuggestionsProvider() { return { name: field.name, help: field.type }; }); }, - timefield: async function(partial, functionArgs) { + async timefield(partial: string, functionArgs: FunctionArg[]) { const indexPattern = await getIndexPattern(functionArgs); if (!indexPattern) { return []; @@ -150,7 +168,10 @@ export function ArgValueSuggestionsProvider() { * @param {string} argName - user provided argument name * @return {boolean} true when dynamic suggestion handler provided for function argument */ - hasDynamicSuggestionsForArgument: (functionName, argName) => { + hasDynamicSuggestionsForArgument: ( + functionName: T, + argName: keyof typeof customHandlers[T] + ) => { return customHandlers[functionName] && customHandlers[functionName][argName]; }, @@ -161,12 +182,13 @@ export function ArgValueSuggestionsProvider() { * @param {string} partial - user provided argument value * @return {array} array of dynamic suggestions matching partial */ - getDynamicSuggestionsForArgument: async ( - functionName, - argName, - functionArgs, + getDynamicSuggestionsForArgument: async ( + functionName: T, + argName: keyof typeof customHandlers[T], + functionArgs: FunctionArg[], partialInput = '' ) => { + // @ts-ignore return await customHandlers[functionName][argName](partialInput, functionArgs); }, @@ -175,7 +197,10 @@ export function ArgValueSuggestionsProvider() { * @param {array} staticSuggestions - argument value suggestions * @return {array} array of static suggestions matching partial */ - getStaticSuggestionsForInput: (partialInput = '', staticSuggestions = []) => { + getStaticSuggestionsForInput: ( + partialInput = '', + staticSuggestions: TimelionFunctionArgs['suggestions'] = [] + ) => { if (partialInput) { return staticSuggestions.filter(suggestion => { return suggestion.name.includes(partialInput); @@ -186,3 +211,5 @@ export function ArgValueSuggestionsProvider() { }, }; } + +export type ArgValueSuggestions = ReturnType; diff --git a/src/legacy/core_plugins/timelion/public/services/plugin_services.ts b/src/legacy/core_plugins/timelion/public/services/plugin_services.ts new file mode 100644 index 0000000000000..5ba4ee5e47983 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/services/plugin_services.ts @@ -0,0 +1,30 @@ +/* + * 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 { IndexPatternsContract } from 'src/plugins/data/public'; +import { SavedObjectsClientContract } from 'kibana/public'; +import { createGetterSetter } from '../../../../../plugins/kibana_utils/public'; + +export const [getIndexPatterns, setIndexPatterns] = createGetterSetter( + 'IndexPatterns' +); + +export const [getSavedObjectsClient, setSavedObjectsClient] = createGetterSetter< + SavedObjectsClientContract +>('SavedObjectsClient'); diff --git a/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts b/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts index 474f464a550cd..206f9f5d8368d 100644 --- a/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts +++ b/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts @@ -28,7 +28,7 @@ const name = 'timelion_vis'; interface Arguments { expression: string; - interval: any; + interval: string; } interface RenderValue { @@ -38,7 +38,7 @@ interface RenderValue { } type Context = KibanaContext | null; -type VisParams = Arguments; +export type VisParams = Arguments; type Return = Promise>; export const getTimelionVisualizationConfig = ( @@ -60,7 +60,7 @@ export const getTimelionVisualizationConfig = ( help: '', }, interval: { - types: ['string', 'null'], + types: ['string'], default: 'auto', help: '', }, diff --git a/src/legacy/core_plugins/timelion/public/vis/_index.scss b/src/legacy/core_plugins/timelion/public/vis/_index.scss index e44b6336d33c1..17a2018f7a56a 100644 --- a/src/legacy/core_plugins/timelion/public/vis/_index.scss +++ b/src/legacy/core_plugins/timelion/public/vis/_index.scss @@ -1 +1,2 @@ @import './timelion_vis'; +@import './timelion_editor'; diff --git a/src/legacy/core_plugins/timelion/public/vis/_timelion_editor.scss b/src/legacy/core_plugins/timelion/public/vis/_timelion_editor.scss new file mode 100644 index 0000000000000..a9331930a86ff --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/vis/_timelion_editor.scss @@ -0,0 +1,15 @@ +.visEditor--timelion { + vis-options-react-wrapper, + .visEditorSidebar__options, + .visEditorSidebar__timelionOptions { + flex: 1 1 auto; + display: flex; + flex-direction: column; + } + + .visEditor__sidebar { + @include euiBreakpoint('xs', 's', 'm') { + width: 100%; + } + } +} diff --git a/src/legacy/core_plugins/timelion/public/vis/index.ts b/src/legacy/core_plugins/timelion/public/vis/index.tsx similarity index 80% rename from src/legacy/core_plugins/timelion/public/vis/index.ts rename to src/legacy/core_plugins/timelion/public/vis/index.tsx index 7b82553a24e5b..1edcb0a5ce71c 100644 --- a/src/legacy/core_plugins/timelion/public/vis/index.ts +++ b/src/legacy/core_plugins/timelion/public/vis/index.tsx @@ -17,19 +17,24 @@ * under the License. */ +import React from 'react'; import { i18n } from '@kbn/i18n'; // @ts-ignore import { DefaultEditorSize } from 'ui/vis/editor_size'; +import { VisOptionsProps } from 'ui/vis/editors/default'; +import { KibanaContextProvider } from '../../../../../plugins/kibana_react/public'; import { getTimelionRequestHandler } from './timelion_request_handler'; import visConfigTemplate from './timelion_vis.html'; -import editorConfigTemplate from './timelion_vis_params.html'; import { TimelionVisualizationDependencies } from '../plugin'; // @ts-ignore import { AngularVisController } from '../../../../ui/public/vis/vis_types/angular_vis_type'; +import { TimelionOptions } from './timelion_options'; +import { VisParams } from '../timelion_vis_fn'; export const TIMELION_VIS_NAME = 'timelion'; export function getTimelionVisualization(dependencies: TimelionVisualizationDependencies) { + const { http, uiSettings } = dependencies; const timelionRequestHandler = getTimelionRequestHandler(dependencies); // return the visType object, which kibana will use to display and configure new @@ -50,7 +55,11 @@ export function getTimelionVisualization(dependencies: TimelionVisualizationDepe template: visConfigTemplate, }, editorConfig: { - optionsTemplate: editorConfigTemplate, + optionsTemplate: (props: VisOptionsProps) => ( + + + + ), defaultSize: DefaultEditorSize.MEDIUM, }, requestHandler: timelionRequestHandler, diff --git a/src/legacy/core_plugins/timelion/public/vis/timelion_options.tsx b/src/legacy/core_plugins/timelion/public/vis/timelion_options.tsx new file mode 100644 index 0000000000000..527fcc3bc6ce8 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/vis/timelion_options.tsx @@ -0,0 +1,48 @@ +/* + * 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, { useCallback } from 'react'; +import { EuiPanel } from '@elastic/eui'; + +import { VisOptionsProps } from 'ui/vis/editors/default'; +import { VisParams } from '../timelion_vis_fn'; +import { TimelionInterval, TimelionExpressionInput } from '../components'; + +function TimelionOptions({ stateParams, setValue, setValidity }: VisOptionsProps) { + const setInterval = useCallback((value: VisParams['interval']) => setValue('interval', value), [ + setValue, + ]); + const setExpressionInput = useCallback( + (value: VisParams['expression']) => setValue('expression', value), + [setValue] + ); + + return ( + + + + + ); +} + +export { TimelionOptions }; diff --git a/src/legacy/core_plugins/timelion/public/vis/timelion_vis_params.html b/src/legacy/core_plugins/timelion/public/vis/timelion_vis_params.html deleted file mode 100644 index 9f2d2094fb1f7..0000000000000 --- a/src/legacy/core_plugins/timelion/public/vis/timelion_vis_params.html +++ /dev/null @@ -1,27 +0,0 @@ -
-
- -
- -
-
- -
-
- -
- - -
- -
diff --git a/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts b/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts index 6e32a4454e707..798902aa133de 100644 --- a/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts +++ b/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts @@ -17,6 +17,8 @@ * under the License. */ +import { TimelionFunctionArgs } from '../../../common/types'; + export interface TimelionFunctionInterface extends TimelionFunctionConfig { chainable: boolean; originalFn: Function; @@ -32,21 +34,6 @@ export interface TimelionFunctionConfig { args: TimelionFunctionArgs[]; } -export interface TimelionFunctionArgs { - name: string; - help?: string; - multi?: boolean; - types: TimelionFunctionArgsTypes[]; - suggestions?: TimelionFunctionArgsSuggestion[]; -} - -export type TimelionFunctionArgsTypes = 'seriesList' | 'number' | 'string' | 'boolean' | 'null'; - -export interface TimelionFunctionArgsSuggestion { - name: string; - help: string; -} - // eslint-disable-next-line import/no-default-export export default class TimelionFunction { constructor(name: string, config: TimelionFunctionConfig); diff --git a/src/legacy/core_plugins/timelion/server/types.ts b/src/legacy/core_plugins/timelion/server/types.ts index e612bc14a0daa..a035d64f764f1 100644 --- a/src/legacy/core_plugins/timelion/server/types.ts +++ b/src/legacy/core_plugins/timelion/server/types.ts @@ -17,12 +17,5 @@ * under the License. */ -export { - TimelionFunctionInterface, - TimelionFunctionConfig, - TimelionFunctionArgs, - TimelionFunctionArgsSuggestion, - TimelionFunctionArgsTypes, -} from './lib/classes/timelion_function'; - +export { TimelionFunctionInterface, TimelionFunctionConfig } from './lib/classes/timelion_function'; export { TimelionRequestQuery } from './routes/run'; diff --git a/src/plugins/kibana_react/public/code_editor/code_editor.tsx b/src/plugins/kibana_react/public/code_editor/code_editor.tsx index 0ae77995c0502..62440f12c6d84 100644 --- a/src/plugins/kibana_react/public/code_editor/code_editor.tsx +++ b/src/plugins/kibana_react/public/code_editor/code_editor.tsx @@ -78,6 +78,13 @@ export interface Props { */ hoverProvider?: monacoEditor.languages.HoverProvider; + /** + * Language config provider for bracket + * Documentation for the provider can be found here: + * https://microsoft.github.io/monaco-editor/api/interfaces/monaco.languages.languageconfiguration.html + */ + languageConfiguration?: monacoEditor.languages.LanguageConfiguration; + /** * Function called before the editor is mounted in the view */ @@ -130,6 +137,13 @@ export class CodeEditor extends React.Component { if (this.props.hoverProvider) { monaco.languages.registerHoverProvider(this.props.languageId, this.props.hoverProvider); } + + if (this.props.languageConfiguration) { + monaco.languages.setLanguageConfiguration( + this.props.languageId, + this.props.languageConfiguration + ); + } }); // Register the theme diff --git a/yarn.lock b/yarn.lock index 96ec5213badcb..983ad570e0f68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3944,6 +3944,11 @@ resolved "https://registry.yarnpkg.com/@types/parse-link-header/-/parse-link-header-1.0.0.tgz#69f059e40a0fa93dc2e095d4142395ae6adc5d7a" integrity sha512-fCA3btjE7QFeRLfcD0Sjg+6/CnmC66HpMBoRfRzd2raTaWMJV21CCZ0LO8MOqf8onl5n0EPfjq4zDhbyX8SVwA== +"@types/pegjs@^0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@types/pegjs/-/pegjs-0.10.1.tgz#9a2f3961dc62430fdb21061eb0ddbd890f9e3b94" + integrity sha512-ra8IchO9odGQmYKbm+94K58UyKCEKdZh9y0vxhG4pIpOJOBlC1C+ZtBVr6jLs+/oJ4pl+1p/4t3JtBA8J10Vvw== + "@types/pngjs@^3.3.2": version "3.3.2" resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-3.3.2.tgz#8ed3bd655ab3a92ea32ada7a21f618e63b93b1d4" From 58f792cee3e7668ac9f24809a904a600c6c46762 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Thu, 9 Jan 2020 11:56:14 +0100 Subject: [PATCH 34/63] Remove eslint overwrite for src/legacy/core_plugins/kibana (#54222) * Cleanup code * Remove eslint overwrite --- .eslintrc.js | 7 ------- .../np_ready/components/doc/use_es_doc_search.ts | 2 +- .../field_chooser/discover_field_search.tsx | 11 ++++++----- .../field_chooser/discover_index_pattern.tsx | 14 +++++++------- ...e.ts => discover_index_pattern_directive.tsx} | 16 ++++++++++++++-- 5 files changed, 28 insertions(+), 22 deletions(-) rename src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/{discover_index_pattern_directive.ts => discover_index_pattern_directive.tsx} (65%) diff --git a/.eslintrc.js b/.eslintrc.js index c43366abf0c3d..9719d1b35548e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -82,13 +82,6 @@ module.exports = { 'react-hooks/exhaustive-deps': 'off', }, }, - { - files: ['src/legacy/core_plugins/kibana/**/*.{js,ts,tsx}'], - rules: { - 'react-hooks/rules-of-hooks': 'off', - 'react-hooks/exhaustive-deps': 'off', - }, - }, { files: ['src/legacy/core_plugins/tile_map/**/*.{js,ts,tsx}'], rules: { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts index a40d9731a04f5..d3bf3696c08a5 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts @@ -91,7 +91,7 @@ export function useEsDocSearch({ useEffect(() => { requestData(); - }, []); + }); return [status, hit, indexPattern]; } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_field_search.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_field_search.tsx index d5f6b63d12199..2910ff2825fe7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_field_search.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_field_search.tsx @@ -67,11 +67,6 @@ export interface Props { * Additionally there's a button displayed that allows the user to show/hide more filter fields */ export function DiscoverFieldSearch({ onChange, value, types }: Props) { - if (typeof value !== 'string') { - // at initial rendering value is undefined (angular related), this catches the warning - // should be removed once all is react - return null; - } const searchPlaceholder = i18n.translate('kbn.discover.fieldChooser.searchPlaceHolder', { defaultMessage: 'Search field names', }); @@ -99,6 +94,12 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { missing: true, }); + if (typeof value !== 'string') { + // at initial rendering value is undefined (angular related), this catches the warning + // should be removed once all is react + return null; + } + const filterBtnAriaLabel = isPopoverOpen ? i18n.translate('kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel', { defaultMessage: 'Hide field filter settings', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern.tsx index 37338decce2c2..a4e8ee2ca3d8a 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern.tsx @@ -45,19 +45,19 @@ export function DiscoverIndexPattern({ selectedIndexPattern, setIndexPattern, }: DiscoverIndexPatternProps) { - if (!indexPatternList || indexPatternList.length === 0 || !selectedIndexPattern) { - // just in case, shouldn't happen - return null; - } - const options: IndexPatternRef[] = indexPatternList.map(entity => ({ + const options: IndexPatternRef[] = (indexPatternList || []).map(entity => ({ id: entity.id, title: entity.attributes!.title, })); + const { id: selectedId, attributes } = selectedIndexPattern || {}; const [selected, setSelected] = useState({ - id: selectedIndexPattern.id, - title: selectedIndexPattern.attributes!.title, + id: selectedId, + title: attributes?.title || '', }); + if (!selectedId) { + return null; + } return (
diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.tsx similarity index 65% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.ts rename to src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.tsx index 8bbeac086f093..d6527b0d7beed 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.tsx @@ -16,11 +16,23 @@ * specific language governing permissions and limitations * under the License. */ +import React from 'react'; import { wrapInI18nContext } from '../../../kibana_services'; -import { DiscoverIndexPattern } from './discover_index_pattern'; +import { DiscoverIndexPattern, DiscoverIndexPatternProps } from './discover_index_pattern'; + +/** + * At initial rendering the angular directive the selectedIndexPattern prop is undefined + * This wrapper catches this, had to be introduced to satisfy eslint + */ +export function DiscoverIndexPatternWrapper(props: DiscoverIndexPatternProps) { + if (!props.selectedIndexPattern || !Array.isArray(props.indexPatternList)) { + return null; + } + return ; +} export function createIndexPatternSelectDirective(reactDirective: any) { - return reactDirective(wrapInI18nContext(DiscoverIndexPattern), [ + return reactDirective(wrapInI18nContext(DiscoverIndexPatternWrapper), [ ['indexPatternList', { watchDepth: 'reference' }], ['selectedIndexPattern', { watchDepth: 'reference' }], ['setIndexPattern', { watchDepth: 'reference' }], From 44660593277aa710b3afb6a416874095b5797009 Mon Sep 17 00:00:00 2001 From: DeFazio Date: Thu, 9 Jan 2020 08:46:46 -0500 Subject: [PATCH 35/63] [ML] Updates Anomaly Detection job wizard button styles, page panel and titles (#53340) * Update button styles, page panel and page title * Add getJobCreatorTitle function for human readable job type name * Add formatMessage to Create job title * Fix translation test * Update tests --- .../full_time_range_selector.test.tsx.snap | 1 - .../full_time_range_selector.tsx | 1 - .../common/job_creator/util/general.ts | 28 ++++++++++ .../components/wizard_nav/wizard_nav.tsx | 15 ++++-- .../jobs/new_job/pages/new_job/page.tsx | 54 ++++++++++++++----- .../new_job/pages/new_job/wizard_steps.tsx | 4 +- .../translations/translations/ja-JP.json | 2 +- .../translations/translations/zh-CN.json | 2 +- 8 files changed, 84 insertions(+), 23 deletions(-) diff --git a/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap b/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap index 0e14f0b402fb2..d633b755ec6ba 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap @@ -3,7 +3,6 @@ exports[`FullTimeRangeSelector renders the selector 1`] = ` diff --git a/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/full_time_range_selector.tsx b/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/full_time_range_selector.tsx index ef2a4c28a570d..4460ced7079c3 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/full_time_range_selector.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/full_time_range_selector.tsx @@ -31,7 +31,6 @@ export const FullTimeRangeSelector: FC = ({ indexPattern, query, disabled } return ( setRange(indexPattern, query)} data-test-subj="mlButtonUseFullData" diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 760dbe447dc89..29af08b8826ee 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { Job, Datafeed, Detector } from '../configs'; import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; import { @@ -270,3 +271,30 @@ export function advancedStartDatafeed(jobCreator: JobCreatorType) { export function aggFieldPairsCanBeCharted(afs: AggFieldPair[]) { return afs.some(a => a.agg.dslName === null) === false; } + +export function getJobCreatorTitle(jobCreator: JobCreatorType) { + switch (jobCreator.type) { + case JOB_TYPE.SINGLE_METRIC: + return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.singleMetric', { + defaultMessage: 'Single metric', + }); + case JOB_TYPE.MULTI_METRIC: + return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.multiMetric', { + defaultMessage: 'Multi metric', + }); + case JOB_TYPE.POPULATION: + return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.population', { + defaultMessage: 'Population', + }); + case JOB_TYPE.ADVANCED: + return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.advanced', { + defaultMessage: 'Advanced', + }); + // case JOB_TYPE.CATEGORIZATION: + // return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.categorization', { + // defaultMessage: 'Categorization', + // }); + default: + return ''; + } +} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/wizard_nav/wizard_nav.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/wizard_nav/wizard_nav.tsx index b0c5758fdd2e7..92429cf907592 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/wizard_nav/wizard_nav.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/wizard_nav/wizard_nav.tsx @@ -8,7 +8,13 @@ import React, { FC, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, +} from '@elastic/eui'; interface StepsNavProps { previousActive?: boolean; @@ -44,20 +50,23 @@ export const WizardNav: FC = ({ ); export const PreviousButton: FC = ({ previous, previousActive = true }) => ( - - + ); export const NextButton: FC = ({ next, nextActive = true }) => ( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx index 79f98c1170ff8..2e025c4931818 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx @@ -5,13 +5,22 @@ */ import React, { FC, useEffect, Fragment } from 'react'; - -import { EuiPage, EuiPageBody, EuiPageContentBody } from '@elastic/eui'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiTitle, + EuiPageContentBody, +} from '@elastic/eui'; import { toastNotifications } from 'ui/notify'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { Wizard } from './wizard'; import { WIZARD_STEPS } from '../components/step_types'; import { jobCreatorFactory, isAdvancedJobCreator } from '../../common/job_creator'; +import { getJobCreatorTitle } from '../../common/job_creator/util/general'; import { JOB_TYPE, DEFAULT_MODEL_MEMORY_LIMIT, @@ -37,7 +46,6 @@ export interface PageProps { export const Page: FC = ({ existingJobsAndGroups, jobType }) => { const kibanaContext = useKibanaContext(); - const jobCreator = jobCreatorFactory(jobType)( kibanaContext.currentIndexPattern, kibanaContext.currentSavedSearch, @@ -149,21 +157,39 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { }; }); + const jobCreatorTitle = getJobCreatorTitle(jobCreator); + return ( - - - + + + + +

+ + : {jobCreatorTitle} +

+
+
+
+ + + + +
diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx index 8e81c05092c98..df21e70a68ed9 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx @@ -143,8 +143,8 @@ export const WizardSteps: FC = ({ currentStep, setCurrentStep }) => { const Title: FC<{ 'data-test-subj': string }> = ({ 'data-test-subj': dataTestSubj, children }) => { return ( - -

{children}

+ +

{children}

diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 545a4fc7c8f93..ee33ff5c568aa 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12704,4 +12704,4 @@ "xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "ライセンスを更新", "xpack.licensing.welcomeBanner.licenseIsExpiredTitle": "ご使用の {licenseType} ライセンスは期限切れです" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 05251d20a66e4..ced7c4e822c27 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12793,4 +12793,4 @@ "xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "更新您的许可", "xpack.licensing.welcomeBanner.licenseIsExpiredTitle": "您的{licenseType}许可已过期" } -} +} \ No newline at end of file From a8c2d5cd58f9d34dc1c33999cf1590237b25356c Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Thu, 9 Jan 2020 17:19:56 +0300 Subject: [PATCH 36/63] [NP] Add lifecycle timeout (#54129) * add promise timeout decorator * crash Kibana if lifecycle takes > 30sec Co-authored-by: Elastic Machine --- .../public/plugins/plugins_service.test.ts | 59 +++++++++++++++++ src/core/public/plugins/plugins_service.ts | 26 +++++--- .../server/plugins/plugins_system.test.ts | 48 ++++++++++++++ src/core/server/plugins/plugins_system.ts | 27 +++++--- src/core/utils/index.ts | 1 + src/core/utils/promise.test.ts | 63 +++++++++++++++++++ src/core/utils/promise.ts | 33 ++++++++++ 7 files changed, 237 insertions(+), 20 deletions(-) create mode 100644 src/core/utils/promise.test.ts create mode 100644 src/core/utils/promise.ts diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 281778f9420dd..cafc7e5887e38 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -279,6 +279,37 @@ describe('PluginsService', () => { expect((contracts.get('pluginA')! as any).setupValue).toEqual(1); expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(2); }); + + describe('timeout', () => { + const flushPromises = () => new Promise(resolve => setImmediate(resolve)); + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + + it('throws timeout error if "setup" was not completed in 30 sec.', async () => { + mockPluginInitializers.set( + 'pluginA', + jest.fn(() => ({ + setup: jest.fn(() => new Promise(i => i)), + start: jest.fn(() => ({ value: 1 })), + stop: jest.fn(), + })) + ); + const pluginsService = new PluginsService(mockCoreContext, plugins); + const promise = pluginsService.setup(mockSetupDeps); + + jest.runAllTimers(); // load plugin bundles + await flushPromises(); + jest.runAllTimers(); // setup plugins + + await expect(promise).rejects.toMatchInlineSnapshot( + `[Error: Setup lifecycle of "pluginA" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.]` + ); + }); + }); }); describe('#start()', () => { @@ -331,6 +362,34 @@ describe('PluginsService', () => { expect((contracts.get('pluginA')! as any).startValue).toEqual(2); expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(3); }); + describe('timeout', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + + it('throws timeout error if "start" was not completed in 30 sec.', async () => { + mockPluginInitializers.set( + 'pluginA', + jest.fn(() => ({ + setup: jest.fn(() => ({ value: 1 })), + start: jest.fn(() => new Promise(i => i)), + stop: jest.fn(), + })) + ); + const pluginsService = new PluginsService(mockCoreContext, plugins); + await pluginsService.setup(mockSetupDeps); + + const promise = pluginsService.start(mockStartDeps); + jest.runAllTimers(); + + await expect(promise).rejects.toMatchInlineSnapshot( + `[Error: Start lifecycle of "pluginA" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.]` + ); + }); + }); }); describe('#stop()', () => { diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts index c1939a3397647..8e1574d05baf8 100644 --- a/src/core/public/plugins/plugins_service.ts +++ b/src/core/public/plugins/plugins_service.ts @@ -28,7 +28,9 @@ import { } from './plugin_context'; import { InternalCoreSetup, InternalCoreStart } from '../core_system'; import { InjectedPluginMetadata } from '../injected_metadata'; +import { withTimeout } from '../../utils'; +const Sec = 1000; /** @internal */ export type PluginsServiceSetupDeps = InternalCoreSetup; /** @internal */ @@ -110,13 +112,15 @@ export class PluginsService implements CoreService ); - contracts.set( - pluginName, - await plugin.setup( + const contract = await withTimeout({ + promise: plugin.setup( createPluginSetupContext(this.coreContext, deps, plugin), pluginDepContracts - ) - ); + ), + timeout: 30 * Sec, + errorMessage: `Setup lifecycle of "${pluginName}" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.`, + }); + contracts.set(pluginName, contract); this.satupPlugins.push(pluginName); } @@ -142,13 +146,15 @@ export class PluginsService implements CoreService ); - contracts.set( - pluginName, - await plugin.start( + const contract = await withTimeout({ + promise: plugin.start( createPluginStartContext(this.coreContext, deps, plugin), pluginDepContracts - ) - ); + ), + timeout: 30 * Sec, + errorMessage: `Start lifecycle of "${pluginName}" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.`, + }); + contracts.set(pluginName, contract); } // Expose start contracts diff --git a/src/core/server/plugins/plugins_system.test.ts b/src/core/server/plugins/plugins_system.test.ts index 18c04af3bb641..22dfbeecbaedd 100644 --- a/src/core/server/plugins/plugins_system.test.ts +++ b/src/core/server/plugins/plugins_system.test.ts @@ -414,3 +414,51 @@ test('`startPlugins` only starts plugins that were setup', async () => { ] `); }); + +describe('setup', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + it('throws timeout error if "setup" was not completed in 30 sec.', async () => { + const plugin: PluginWrapper = createPlugin('timeout-setup'); + jest.spyOn(plugin, 'setup').mockImplementation(() => new Promise(i => i)); + pluginsSystem.addPlugin(plugin); + mockCreatePluginSetupContext.mockImplementation(() => ({})); + + const promise = pluginsSystem.setupPlugins(setupDeps); + jest.runAllTimers(); + + await expect(promise).rejects.toMatchInlineSnapshot( + `[Error: Setup lifecycle of "timeout-setup" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.]` + ); + }); +}); + +describe('start', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + it('throws timeout error if "start" was not completed in 30 sec.', async () => { + const plugin: PluginWrapper = createPlugin('timeout-start'); + jest.spyOn(plugin, 'setup').mockResolvedValue({}); + jest.spyOn(plugin, 'start').mockImplementation(() => new Promise(i => i)); + + pluginsSystem.addPlugin(plugin); + mockCreatePluginSetupContext.mockImplementation(() => ({})); + mockCreatePluginStartContext.mockImplementation(() => ({})); + + await pluginsSystem.setupPlugins(setupDeps); + const promise = pluginsSystem.startPlugins(startDeps); + jest.runAllTimers(); + + await expect(promise).rejects.toMatchInlineSnapshot( + `[Error: Start lifecycle of "timeout-start" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.]` + ); + }); +}); diff --git a/src/core/server/plugins/plugins_system.ts b/src/core/server/plugins/plugins_system.ts index f437b51e5b07a..dd2df7c8e01d1 100644 --- a/src/core/server/plugins/plugins_system.ts +++ b/src/core/server/plugins/plugins_system.ts @@ -23,7 +23,9 @@ import { PluginWrapper } from './plugin'; import { DiscoveredPlugin, PluginName, PluginOpaqueId } from './types'; import { createPluginSetupContext, createPluginStartContext } from './plugin_context'; import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service'; +import { withTimeout } from '../../utils'; +const Sec = 1000; /** @internal */ export class PluginsSystem { private readonly plugins = new Map(); @@ -85,14 +87,16 @@ export class PluginsSystem { return depContracts; }, {} as Record); - contracts.set( - pluginName, - await plugin.setup( + const contract = await withTimeout({ + promise: plugin.setup( createPluginSetupContext(this.coreContext, deps, plugin), pluginDepContracts - ) - ); + ), + timeout: 30 * Sec, + errorMessage: `Setup lifecycle of "${pluginName}" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.`, + }); + contracts.set(pluginName, contract); this.satupPlugins.push(pluginName); } @@ -121,13 +125,16 @@ export class PluginsSystem { return depContracts; }, {} as Record); - contracts.set( - pluginName, - await plugin.start( + const contract = await withTimeout({ + promise: plugin.start( createPluginStartContext(this.coreContext, deps, plugin), pluginDepContracts - ) - ); + ), + timeout: 30 * Sec, + errorMessage: `Start lifecycle of "${pluginName}" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.`, + }); + + contracts.set(pluginName, contract); } return contracts; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index b51cc4ef56410..7f8645e2e2e40 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -24,5 +24,6 @@ export * from './get'; export * from './map_to_object'; export * from './merge'; export * from './pick'; +export * from './promise'; export * from './url'; export * from './unset'; diff --git a/src/core/utils/promise.test.ts b/src/core/utils/promise.test.ts new file mode 100644 index 0000000000000..97d16c5158c7b --- /dev/null +++ b/src/core/utils/promise.test.ts @@ -0,0 +1,63 @@ +/* + * 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 { withTimeout } from './promise'; + +const delay = (ms: number, resolveValue?: any) => + new Promise(resolve => setTimeout(resolve, ms, resolveValue)); + +describe('withTimeout', () => { + it('resolves with a promise value if resolved in given timeout', async () => { + await expect( + withTimeout({ + promise: delay(10, 'value'), + timeout: 200, + errorMessage: 'error-message', + }) + ).resolves.toBe('value'); + }); + + it('rejects with errorMessage if not resolved in given time', async () => { + await expect( + withTimeout({ + promise: delay(200, 'value'), + timeout: 10, + errorMessage: 'error-message', + }) + ).rejects.toMatchInlineSnapshot(`[Error: error-message]`); + + await expect( + withTimeout({ + promise: new Promise(i => i), + timeout: 10, + errorMessage: 'error-message', + }) + ).rejects.toMatchInlineSnapshot(`[Error: error-message]`); + }); + + it('does not swallow promise error', async () => { + await expect( + withTimeout({ + promise: Promise.reject(new Error('from-promise')), + timeout: 10, + errorMessage: 'error-message', + }) + ).rejects.toMatchInlineSnapshot(`[Error: from-promise]`); + }); +}); diff --git a/src/core/utils/promise.ts b/src/core/utils/promise.ts new file mode 100644 index 0000000000000..ae1db9c54eda5 --- /dev/null +++ b/src/core/utils/promise.ts @@ -0,0 +1,33 @@ +/* + * 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 function withTimeout({ + promise, + timeout, + errorMessage, +}: { + promise: Promise; + timeout: number; + errorMessage: string; +}) { + return Promise.race([ + promise, + new Promise((resolve, reject) => setTimeout(() => reject(new Error(errorMessage)), timeout)), + ]) as Promise; +} From 2d15b8c82b1981f7eca2cae20994c8ffcd83e211 Mon Sep 17 00:00:00 2001 From: Andrew Cholakian Date: Thu, 9 Jan 2020 08:36:31 -0600 Subject: [PATCH 37/63] [Uptime] Improve query performance with Heartbeat 7.6+ data. (#52433) This PR optimizes both the snapshot component and the monitor list on the overview page by using the new monitor.timespan field from elastic/beats#14778. Note that the functionality here will work with heartbeats lacking that patch, but the performance improvements will be absent. This PR adapts the snapshot tests to use synthetically generated data which should be easier to maintain. As a result some of that code is refactored as well. See #52433 parent issue as well. --- .../uptime/common/graphql/introspection.json | 12 -- .../plugins/uptime/common/graphql/types.ts | 2 - .../runtime_types/snapshot/snapshot_count.ts | 1 - .../functional/__tests__/snapshot.test.tsx | 1 - .../__snapshots__/donut_chart.test.tsx.snap | 2 + .../donut_chart_legend.test.tsx.snap | 2 + .../donut_chart_legend_row.test.tsx.snap | 1 + .../__tests__/donut_chart_legend_row.test.tsx | 2 +- .../functional/charts/donut_chart.tsx | 2 +- .../functional/charts/donut_chart_legend.tsx | 6 +- .../charts/donut_chart_legend_row.tsx | 7 +- .../monitor_list_drawer/__tests__/data.json | 2 - .../monitor_list_status_column.tsx | 6 - .../__snapshots__/snapshot.test.ts.snap | 7 +- .../state/api/__tests__/snapshot.test.ts | 4 +- .../__snapshots__/snapshot.test.ts.snap | 4 - .../state/reducers/__tests__/snapshot.test.ts | 3 +- .../uptime/public/state/reducers/snapshot.ts | 1 - .../state/selectors/__tests__/index.test.ts | 1 - .../get_snapshot_helper.test.ts.snap | 10 -- .../__tests__/get_snapshot_helper.test.ts | 106 -------------- .../elasticsearch_monitor_states_adapter.ts | 116 +++++++++++---- .../monitor_states/get_snapshot_helper.ts | 40 ------ .../search/__tests__/fetch_page.test.ts | 2 +- .../__tests__/monitor_group_iterator.test.ts | 2 +- .../search/__tests__/test_helpers.ts | 13 +- .../search/enrich_monitor_groups.ts | 48 +++---- .../monitor_states/search/fetch_chunk.ts | 2 +- .../monitor_states/search/fetch_page.ts | 3 +- .../search/find_potential_matches.ts | 20 +-- .../search/monitor_group_iterator.ts | 4 +- .../monitor_states/search/query_context.ts | 135 ++++++++++++++++++ .../search/refine_potential_matches.ts | 5 +- .../translations/translations/ja-JP.json | 6 +- .../translations/translations/zh-CN.json | 6 +- .../uptime/graphql/fixtures/snapshot.json | 5 +- .../graphql/fixtures/snapshot_empty.json | 5 +- .../fixtures/snapshot_filtered_by_down.json | 1 - .../fixtures/snapshot_filtered_by_up.json | 5 +- .../uptime/graphql/helpers/make_checks.ts | 76 +++++++++- .../apis/uptime/graphql/monitor_states.ts | 7 +- .../api_integration/apis/uptime/rest/index.ts | 4 +- .../apis/uptime/rest/snapshot.ts | 114 ++++++++++----- .../test/functional/apps/uptime/overview.ts | 22 +++ .../es_archives/uptime/blank/mappings.json | 3 + .../functional/page_objects/uptime_page.ts | 4 + x-pack/test/functional/services/uptime.ts | 6 + 47 files changed, 493 insertions(+), 343 deletions(-) delete mode 100644 x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/__snapshots__/get_snapshot_helper.test.ts.snap delete mode 100644 x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/get_snapshot_helper.test.ts delete mode 100644 x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/get_snapshot_helper.ts create mode 100644 x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/query_context.ts diff --git a/x-pack/legacy/plugins/uptime/common/graphql/introspection.json b/x-pack/legacy/plugins/uptime/common/graphql/introspection.json index 4d1993233e9ca..29e8435e61b7b 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/introspection.json +++ b/x-pack/legacy/plugins/uptime/common/graphql/introspection.json @@ -2173,18 +2173,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "mixed", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Int", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "total", "description": "", diff --git a/x-pack/legacy/plugins/uptime/common/graphql/types.ts b/x-pack/legacy/plugins/uptime/common/graphql/types.ts index ed7c9ef19f484..9998720d5e378 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/types.ts +++ b/x-pack/legacy/plugins/uptime/common/graphql/types.ts @@ -419,8 +419,6 @@ export interface SnapshotCount { down: number; - mixed: number; - total: number; } diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/snapshot/snapshot_count.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/snapshot/snapshot_count.ts index d4935c50ff5b8..3abc25530a2dc 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/snapshot/snapshot_count.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/snapshot/snapshot_count.ts @@ -8,7 +8,6 @@ import * as t from 'io-ts'; export const SnapshotType = t.type({ down: t.number, - mixed: t.number, total: t.number, up: t.number, }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/snapshot.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/snapshot.test.tsx index 193f37c8fe56b..d645eb21ac776 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/snapshot.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/snapshot.test.tsx @@ -13,7 +13,6 @@ describe('Snapshot component', () => { const snapshot: Snapshot = { up: 8, down: 2, - mixed: 0, total: 10, }; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap index c8c4177a4907e..9699a1842ccf1 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap @@ -103,6 +103,7 @@ exports[`DonutChart component renders a donut chart 1`] = ` 32 @@ -150,6 +151,7 @@ exports[`DonutChart component renders a donut chart 1`] = ` 95 diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend.test.tsx.snap index 3674497b538f3..e971576521b7d 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend.test.tsx.snap @@ -5,6 +5,7 @@ exports[`DonutChartLegend applies valid props as expected 1`] = ` diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend_row.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend_row.test.tsx.snap index 5f6508e299a28..bc6033ea7109a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend_row.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend_row.test.tsx.snap @@ -21,6 +21,7 @@ exports[`DonutChartLegendRow passes appropriate props 1`] = ` 23 diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/donut_chart_legend_row.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/donut_chart_legend_row.test.tsx index 82e2279edd892..49e887cc8f96c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/donut_chart_legend_row.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/donut_chart_legend_row.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; describe('DonutChartLegendRow', () => { it('passes appropriate props', () => { const wrapper = shallowWithIntl( - + ); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart.tsx index e2705e7cbacb3..50dca8577455d 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart.tsx @@ -73,7 +73,7 @@ export const DonutChart = ({ height, down, up, width }: DonutChartProps) => { { ); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart_legend_row.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart_legend_row.tsx index bf684a3446b9a..fc67a86db3b48 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart_legend_row.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart_legend_row.tsx @@ -23,9 +23,10 @@ interface Props { color: string; message: string; content: string | number; + 'data-test-subj': string; } -export const DonutChartLegendRow = ({ color, content, message }: Props) => ( +export const DonutChartLegendRow = ({ color, content, message, 'data-test-subj': dts }: Props) => ( @@ -33,6 +34,8 @@ export const DonutChartLegendRow = ({ color, content, message }: Props) => ( {message} - {content} + + {content} + ); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/data.json b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/data.json index 64adf3642fb22..a45e974685b9c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/data.json +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/data.json @@ -271,7 +271,6 @@ "monitor": { "id": null, "name": "elastic", - "status": "mixed", "type": null, "__typename": "MonitorState" }, @@ -377,7 +376,6 @@ "monitor": { "id": null, "name": null, - "status": "mixed", "type": null, "__typename": "MonitorState" }, diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_status_column.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_status_column.tsx index 463048512e1e0..0a3a0962a4d09 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_status_column.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_status_column.tsx @@ -20,8 +20,6 @@ const getHealthColor = (status: string): string => { return 'success'; case 'down': return 'danger'; - case 'mixed': - return 'warning'; default: return ''; } @@ -37,10 +35,6 @@ const getHealthMessage = (status: string): string | null => { return i18n.translate('xpack.uptime.monitorList.statusColumn.downLabel', { defaultMessage: 'Down', }); - case 'mixed': - return i18n.translate('xpack.uptime.monitorList.statusColumn.mixedLabel', { - defaultMessage: 'Mixed', - }); default: return null; } diff --git a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap index 53716681664c2..0d2392390c7e4 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap @@ -1,8 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`snapshot API throws when server response doesn't correspond to expected type 1`] = ` -[Error: Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/down: number -Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/mixed: number -Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/total: number -Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/up: number] +[Error: Invalid value undefined supplied to : { down: number, total: number, up: number }/down: number +Invalid value undefined supplied to : { down: number, total: number, up: number }/total: number +Invalid value undefined supplied to : { down: number, total: number, up: number }/up: number] `; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts index f5fdfb172bc58..e9b1391a23e32 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts @@ -14,7 +14,7 @@ describe('snapshot API', () => { fetchMock = jest.spyOn(window, 'fetch'); mockResponse = { ok: true, - json: () => new Promise(r => r({ up: 3, down: 12, mixed: 0, total: 15 })), + json: () => new Promise(r => r({ up: 3, down: 12, total: 15 })), }; }); @@ -34,7 +34,7 @@ describe('snapshot API', () => { expect(fetchMock).toHaveBeenCalledWith( '/api/uptime/snapshot/count?dateRangeStart=now-15m&dateRangeEnd=now&filters=monitor.id%3A%22auto-http-0X21EE76EAC459873F%22&statusFilter=up' ); - expect(resp).toEqual({ up: 3, down: 12, mixed: 0, total: 15 }); + expect(resp).toEqual({ up: 3, down: 12, total: 15 }); }); it(`throws when server response doesn't correspond to expected type`, async () => { diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/snapshot.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/snapshot.test.ts.snap index d3a21ec9eece3..7a3c72f93d86f 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,7 +4,6 @@ exports[`snapshot reducer appends a current error to existing errors list 1`] = Object { "count": Object { "down": 0, - "mixed": 0, "total": 0, "up": 0, }, @@ -19,7 +18,6 @@ exports[`snapshot reducer changes the count when a snapshot fetch succeeds 1`] = Object { "count": Object { "down": 15, - "mixed": 0, "total": 25, "up": 10, }, @@ -32,7 +30,6 @@ exports[`snapshot reducer sets the state's status to loading during a fetch 1`] Object { "count": Object { "down": 0, - "mixed": 0, "total": 0, "up": 0, }, @@ -45,7 +42,6 @@ exports[`snapshot reducer updates existing state 1`] = ` Object { "count": Object { "down": 1, - "mixed": 0, "total": 4, "up": 3, }, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts index a4b317d5af197..95c576e0fd72e 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts @@ -21,7 +21,7 @@ describe('snapshot reducer', () => { expect( snapshotReducer( { - count: { down: 1, mixed: 0, total: 4, up: 3 }, + count: { down: 1, total: 4, up: 3 }, errors: [], loading: false, }, @@ -47,7 +47,6 @@ describe('snapshot reducer', () => { payload: { up: 10, down: 15, - mixed: 0, total: 25, }, }; diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts index dd9449325f4fb..2155d0e3a74e3 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts @@ -21,7 +21,6 @@ export interface SnapshotState { const initialState: SnapshotState = { count: { down: 0, - mixed: 0, total: 0, up: 0, }, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts index 33b6400c56c60..2378770601594 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -19,7 +19,6 @@ describe('state selectors', () => { count: { up: 2, down: 0, - mixed: 0, total: 2, }, errors: [], diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/__snapshots__/get_snapshot_helper.test.ts.snap b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/__snapshots__/get_snapshot_helper.test.ts.snap deleted file mode 100644 index 29c82ff455d36..0000000000000 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/__snapshots__/get_snapshot_helper.test.ts.snap +++ /dev/null @@ -1,10 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`get snapshot helper reduces check groups as expected 1`] = ` -Object { - "down": 1, - "mixed": 0, - "total": 3, - "up": 2, -} -`; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/get_snapshot_helper.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/get_snapshot_helper.test.ts deleted file mode 100644 index 917e4a149de67..0000000000000 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/get_snapshot_helper.test.ts +++ /dev/null @@ -1,106 +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 { getSnapshotCountHelper } from '../get_snapshot_helper'; -import { MonitorGroups } from '../search'; - -describe('get snapshot helper', () => { - let mockIterator: any; - beforeAll(() => { - mockIterator = jest.fn(); - const summaryTimestamp = new Date('2019-01-01'); - const firstResult: MonitorGroups = { - id: 'firstGroup', - groups: [ - { - monitorId: 'first-monitor', - location: 'us-east-1', - checkGroup: 'abc', - status: 'down', - summaryTimestamp, - }, - { - monitorId: 'first-monitor', - location: 'us-west-1', - checkGroup: 'abc', - status: 'up', - summaryTimestamp, - }, - { - monitorId: 'first-monitor', - location: 'amsterdam', - checkGroup: 'abc', - status: 'down', - summaryTimestamp, - }, - ], - }; - const secondResult: MonitorGroups = { - id: 'secondGroup', - groups: [ - { - monitorId: 'second-monitor', - location: 'us-east-1', - checkGroup: 'yyz', - status: 'up', - summaryTimestamp, - }, - { - monitorId: 'second-monitor', - location: 'us-west-1', - checkGroup: 'yyz', - status: 'up', - summaryTimestamp, - }, - { - monitorId: 'second-monitor', - location: 'amsterdam', - checkGroup: 'yyz', - status: 'up', - summaryTimestamp, - }, - ], - }; - const thirdResult: MonitorGroups = { - id: 'thirdGroup', - groups: [ - { - monitorId: 'third-monitor', - location: 'us-east-1', - checkGroup: 'dt', - status: 'up', - summaryTimestamp, - }, - { - monitorId: 'third-monitor', - location: 'us-west-1', - checkGroup: 'dt', - status: 'up', - summaryTimestamp, - }, - { - monitorId: 'third-monitor', - location: 'amsterdam', - checkGroup: 'dt', - status: 'up', - summaryTimestamp, - }, - ], - }; - - const mockNext = jest - .fn() - .mockReturnValueOnce(firstResult) - .mockReturnValueOnce(secondResult) - .mockReturnValueOnce(thirdResult) - .mockReturnValueOnce(null); - mockIterator.next = mockNext; - }); - - it('reduces check groups as expected', async () => { - expect(await getSnapshotCountHelper(mockIterator)).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/elasticsearch_monitor_states_adapter.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/elasticsearch_monitor_states_adapter.ts index d264da2e7ec0c..eaaa8087e57cd 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/elasticsearch_monitor_states_adapter.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/elasticsearch_monitor_states_adapter.ts @@ -4,22 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { UMMonitorStatesAdapter, CursorPagination } from './adapter_types'; +import { UMMonitorStatesAdapter } from './adapter_types'; import { INDEX_NAMES, CONTEXT_DEFAULTS } from '../../../../common/constants'; import { fetchPage } from './search'; import { MonitorGroupIterator } from './search/monitor_group_iterator'; -import { getSnapshotCountHelper } from './get_snapshot_helper'; - -export interface QueryContext { - count: (query: Record) => Promise; - search: (query: Record) => Promise; - dateRangeStart: string; - dateRangeEnd: string; - pagination: CursorPagination; - filterClause: any | null; - size: number; - statusFilter?: string; -} +import { Snapshot } from '../../../../common/runtime_types'; +import { QueryContext } from './search/query_context'; export const elasticsearchMonitorStatesAdapter: UMMonitorStatesAdapter = { // Gets a page of monitor states. @@ -35,16 +25,15 @@ export const elasticsearchMonitorStatesAdapter: UMMonitorStatesAdapter = { statusFilter = statusFilter === null ? undefined : statusFilter; const size = 10; - const queryContext: QueryContext = { - count: (query: Record): Promise => callES('count', query), - search: (query: Record): Promise => callES('search', query), + const queryContext = new QueryContext( + callES, dateRangeStart, dateRangeEnd, pagination, - filterClause: filters && filters !== '' ? JSON.parse(filters) : null, + filters && filters !== '' ? JSON.parse(filters) : null, size, - statusFilter, - }; + statusFilter + ); const page = await fetchPage(queryContext); @@ -55,18 +44,46 @@ export const elasticsearchMonitorStatesAdapter: UMMonitorStatesAdapter = { }; }, - getSnapshotCount: async ({ callES, dateRangeStart, dateRangeEnd, filters, statusFilter }) => { - const context: QueryContext = { - count: query => callES('count', query), - search: query => callES('search', query), + getSnapshotCount: async ({ + callES, + dateRangeStart, + dateRangeEnd, + filters, + statusFilter, + }): Promise => { + if (!(statusFilter === 'up' || statusFilter === 'down' || statusFilter === undefined)) { + throw new Error(`Invalid status filter value '${statusFilter}'`); + } + + const context = new QueryContext( + callES, dateRangeStart, dateRangeEnd, - pagination: CONTEXT_DEFAULTS.CURSOR_PAGINATION, - filterClause: filters && filters !== '' ? JSON.parse(filters) : null, - size: CONTEXT_DEFAULTS.MAX_MONITORS_FOR_SNAPSHOT_COUNT, - statusFilter, + CONTEXT_DEFAULTS.CURSOR_PAGINATION, + filters && filters !== '' ? JSON.parse(filters) : null, + CONTEXT_DEFAULTS.MAX_MONITORS_FOR_SNAPSHOT_COUNT, + statusFilter + ); + + // Calculate the total, up, and down counts. + const counts = await fastStatusCount(context); + + // Check if the last count was accurate, if not, we need to perform a slower count with the + // MonitorGroupsIterator. + if (!(await context.hasTimespan())) { + // Figure out whether 'up' or 'down' is more common. It's faster to count the lower cardinality + // one then use subtraction to figure out its opposite. + const [leastCommonStatus, mostCommonStatus]: Array<'up' | 'down'> = + counts.up > counts.down ? ['down', 'up'] : ['up', 'down']; + counts[leastCommonStatus] = await slowStatusCount(context, leastCommonStatus); + counts[mostCommonStatus] = counts.total - counts[leastCommonStatus]; + } + + return { + total: statusFilter ? counts[statusFilter] : counts.total, + up: statusFilter === 'down' ? 0 : counts.up, + down: statusFilter === 'up' ? 0 : counts.down, }; - return getSnapshotCountHelper(new MonitorGroupIterator(context)); }, statesIndexExists: async ({ callES }) => { @@ -92,3 +109,46 @@ const jsonifyPagination = (p: any): string | null => { return JSON.stringify(p); }; + +const fastStatusCount = async (context: QueryContext): Promise => { + const params = { + index: INDEX_NAMES.HEARTBEAT, + body: { + size: 0, + query: { bool: { filter: await context.dateAndCustomFilters() } }, + aggs: { + unique: { + // We set the precision threshold to 40k which is the max precision supported by cardinality + cardinality: { field: 'monitor.id', precision_threshold: 40000 }, + }, + down: { + filter: { range: { 'summary.down': { gt: 0 } } }, + aggs: { + unique: { cardinality: { field: 'monitor.id', precision_threshold: 40000 } }, + }, + }, + }, + }, + }; + + const statistics = await context.search(params); + const total = statistics.aggregations.unique.value; + const down = statistics.aggregations.down.unique.value; + + return { + total, + down, + up: total - down, + }; +}; + +const slowStatusCount = async (context: QueryContext, status: string): Promise => { + const downContext = context.clone(); + downContext.statusFilter = status; + const iterator = new MonitorGroupIterator(downContext); + let count = 0; + while (await iterator.next()) { + count++; + } + return count; +}; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/get_snapshot_helper.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/get_snapshot_helper.ts deleted file mode 100644 index 8bd21b77406df..0000000000000 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/get_snapshot_helper.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. - */ - -import { MonitorGroups, MonitorGroupIterator } from './search'; -import { Snapshot } from '../../../../common/runtime_types'; - -const reduceItemsToCounts = (items: MonitorGroups[]) => { - let down = 0; - let up = 0; - items.forEach(item => { - if (item.groups.some(group => group.status === 'down')) { - down++; - } else { - up++; - } - }); - return { - down, - mixed: 0, - total: down + up, - up, - }; -}; - -export const getSnapshotCountHelper = async (iterator: MonitorGroupIterator): Promise => { - const items: MonitorGroups[] = []; - let res: MonitorGroups | null; - // query the index to find the most recent check group for each monitor/location - do { - res = await iterator.next(); - if (res) { - items.push(res); - } - } while (res !== null); - - return reduceItemsToCounts(items); -}; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/fetch_page.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/fetch_page.test.ts index d571a5a902539..0bbdaa87a5e66 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/fetch_page.test.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/fetch_page.test.ts @@ -11,7 +11,7 @@ import { MonitorGroupsFetcher, MonitorGroupsPage, } from '../fetch_page'; -import { QueryContext } from '../../elasticsearch_monitor_states_adapter'; +import { QueryContext } from '../query_context'; import { MonitorSummary } from '../../../../../../common/graphql/types'; import { nextPagination, prevPagination, simpleQueryContext } from './test_helpers'; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/monitor_group_iterator.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/monitor_group_iterator.test.ts index b8df3b635dc6b..0ce5e75195475 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/monitor_group_iterator.test.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/monitor_group_iterator.test.ts @@ -11,8 +11,8 @@ import { MonitorGroupIterator, } from '../monitor_group_iterator'; import { simpleQueryContext } from './test_helpers'; -import { QueryContext } from '../../elasticsearch_monitor_states_adapter'; import { MonitorGroups } from '../fetch_page'; +import { QueryContext } from '../query_context'; describe('iteration', () => { let iterator: MonitorGroupIterator | null = null; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/test_helpers.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/test_helpers.ts index d6fe5f82e735d..bb3f3da3e289d 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/test_helpers.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/test_helpers.ts @@ -6,7 +6,7 @@ import { CursorPagination } from '../../adapter_types'; import { CursorDirection, SortOrder } from '../../../../../../common/graphql/types'; -import { QueryContext } from '../../elasticsearch_monitor_states_adapter'; +import { QueryContext } from '../query_context'; export const prevPagination = (key: any): CursorPagination => { return { @@ -23,14 +23,5 @@ export const nextPagination = (key: any): CursorPagination => { }; }; export const simpleQueryContext = (): QueryContext => { - return { - count: _query => new Promise(r => ({})), - search: _query => new Promise(r => ({})), - dateRangeEnd: '', - dateRangeStart: '', - filterClause: undefined, - pagination: nextPagination('something'), - size: 0, - statusFilter: '', - }; + return new QueryContext(undefined, '', '', nextPagination('something'), undefined, 0, ''); }; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/enrich_monitor_groups.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/enrich_monitor_groups.ts index 093e105635c2c..b64015424ff40 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/enrich_monitor_groups.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/enrich_monitor_groups.ts @@ -5,7 +5,7 @@ */ import { get, sortBy } from 'lodash'; -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; +import { QueryContext } from './query_context'; import { getHistogramIntervalFormatted } from '../../../helper'; import { INDEX_NAMES, STATES } from '../../../../../common/constants'; import { @@ -77,19 +77,19 @@ export const enrichMonitorGroups: MonitorEnricher = async ( } String agentIdIP = agentId + "-" + (ip == null ? "" : ip.toString()); def ts = doc["@timestamp"][0].toInstant().toEpochMilli(); - + def lastCheck = state.checksByAgentIdIP[agentId]; Instant lastTs = lastCheck != null ? lastCheck["@timestamp"] : null; if (lastTs != null && lastTs > ts) { return; } - + curCheck.put("@timestamp", ts); - + Map agent = new HashMap(); agent.id = agentId; curCheck.put("agent", agent); - + if (state.globals.url == null) { Map url = new HashMap(); Collection fields = ["full", "original", "scheme", "username", "password", "domain", "port", "path", "query", "fragment"]; @@ -102,7 +102,7 @@ export const enrichMonitorGroups: MonitorEnricher = async ( } state.globals.url = url; } - + Map monitor = new HashMap(); monitor.status = doc["monitor.status"][0]; monitor.ip = ip; @@ -113,7 +113,7 @@ export const enrichMonitorGroups: MonitorEnricher = async ( } } curCheck.monitor = monitor; - + if (curCheck.observer == null) { curCheck.observer = new HashMap(); } @@ -144,14 +144,14 @@ export const enrichMonitorGroups: MonitorEnricher = async ( if (!doc["tls.certificate_not_valid_before"].isEmpty()) { curCheck.tls.certificate_not_valid_before = doc["tls.certificate_not_valid_before"][0]; } - + state.checksByAgentIdIP[agentIdIP] = curCheck; `, combine_script: 'return state;', reduce_script: ` // The final document Map result = new HashMap(); - + Map checks = new HashMap(); Instant maxTs = Instant.ofEpochMilli(0); Collection ips = new HashSet(); @@ -159,7 +159,7 @@ export const enrichMonitorGroups: MonitorEnricher = async ( Collection podUids = new HashSet(); Collection containerIds = new HashSet(); Collection tls = new HashSet(); - String name = null; + String name = null; for (state in states) { result.putAll(state.globals); for (entry in state.checksByAgentIdIP.entrySet()) { @@ -167,18 +167,18 @@ export const enrichMonitorGroups: MonitorEnricher = async ( def check = entry.getValue(); def lastBestCheck = checks.get(agentIdIP); def checkTs = Instant.ofEpochMilli(check.get("@timestamp")); - + if (maxTs.isBefore(checkTs)) { maxTs = checkTs} - + if (lastBestCheck == null || lastBestCheck.get("@timestamp") < checkTs) { check["@timestamp"] = check["@timestamp"]; checks[agentIdIP] = check } - + if (check.monitor.name != null && check.monitor.name != "") { name = check.monitor.name; } - + ips.add(check.monitor.ip); if (check.observer != null && check.observer.geo != null && check.observer.geo.name != null) { geoNames.add(check.observer.geo.name); @@ -194,45 +194,45 @@ export const enrichMonitorGroups: MonitorEnricher = async ( } } } - + // We just use the values so we can store these as nested docs result.checks = checks.values(); result.put("@timestamp", maxTs); - - + + Map summary = new HashMap(); summary.up = checks.entrySet().stream().filter(c -> c.getValue().monitor.status == "up").count(); summary.down = checks.size() - summary.up; result.summary = summary; - + Map monitor = new HashMap(); monitor.ip = ips; monitor.name = name; - monitor.status = summary.down > 0 ? (summary.up > 0 ? "mixed": "down") : "up"; + monitor.status = summary.down > 0 ? "down" : "up"; result.monitor = monitor; - + Map observer = new HashMap(); Map geo = new HashMap(); observer.geo = geo; geo.name = geoNames; result.observer = observer; - + if (!podUids.isEmpty()) { result.kubernetes = new HashMap(); result.kubernetes.pod = new HashMap(); result.kubernetes.pod.uid = podUids; } - + if (!containerIds.isEmpty()) { result.container = new HashMap(); result.container.id = containerIds; } - + if (!tls.isEmpty()) { result.tls = new HashMap(); result.tls = tls; } - + return result; `, }, diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_chunk.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_chunk.ts index e395df0d1d08d..77676ac9a6373 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_chunk.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_chunk.ts @@ -6,8 +6,8 @@ import { refinePotentialMatches } from './refine_potential_matches'; import { findPotentialMatches } from './find_potential_matches'; -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; import { ChunkFetcher, ChunkResult } from './monitor_group_iterator'; +import { QueryContext } from './query_context'; /** * Fetches a single 'chunk' of data with a single query, then uses a secondary query to filter out erroneous matches. diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_page.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_page.ts index 085c11f78b8f5..046bdc8a8d07d 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_page.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_page.ts @@ -6,7 +6,7 @@ import { flatten } from 'lodash'; import { CursorPagination } from '../adapter_types'; -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; +import { QueryContext } from './query_context'; import { QUERY } from '../../../../../common/constants'; import { CursorDirection, MonitorSummary, SortOrder } from '../../../../../common/graphql/types'; import { enrichMonitorGroups } from './enrich_monitor_groups'; @@ -51,6 +51,7 @@ const fetchPageMonitorGroups: MonitorGroupsFetcher = async ( size: number ): Promise => { const monitorGroups: MonitorGroups[] = []; + const iterator = new MonitorGroupIterator(queryContext); let paginationBefore: CursorPagination | null = null; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/find_potential_matches.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/find_potential_matches.ts index 8f5e26b75f56c..e34bc6ab805c0 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/find_potential_matches.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/find_potential_matches.ts @@ -5,10 +5,9 @@ */ import { get, set } from 'lodash'; -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; import { CursorDirection } from '../../../../../common/graphql/types'; import { INDEX_NAMES } from '../../../../../common/constants'; -import { makeDateRangeFilter } from '../../../helper/make_date_rate_filter'; +import { QueryContext } from './query_context'; // This is the first phase of the query. In it, we find the most recent check groups that matched the given query. // Note that these check groups may not be the most recent groups for the matching monitor ID! We'll filter those @@ -55,7 +54,7 @@ export const findPotentialMatches = async ( }; const query = async (queryContext: QueryContext, searchAfter: any, size: number) => { - const body = queryBody(queryContext, searchAfter, size); + const body = await queryBody(queryContext, searchAfter, size); const params = { index: INDEX_NAMES.HEARTBEAT, @@ -65,15 +64,11 @@ const query = async (queryContext: QueryContext, searchAfter: any, size: number) return await queryContext.search(params); }; -const queryBody = (queryContext: QueryContext, searchAfter: any, size: number) => { +const queryBody = async (queryContext: QueryContext, searchAfter: any, size: number) => { const compositeOrder = cursorDirectionToOrder(queryContext.pagination.cursorDirection); - const filters: any[] = [ - makeDateRangeFilter(queryContext.dateRangeStart, queryContext.dateRangeEnd), - ]; - if (queryContext.filterClause) { - filters.push(queryContext.filterClause); - } + const filters = await queryContext.dateAndCustomFilters(); + if (queryContext.statusFilter) { filters.push({ match: { 'monitor.status': queryContext.statusFilter } }); } @@ -82,6 +77,11 @@ const queryBody = (queryContext: QueryContext, searchAfter: any, size: number) = size: 0, query: { bool: { filter: filters } }, aggs: { + has_timespan: { + filter: { + exists: { field: 'monitor.timespan' }, + }, + }, monitors: { composite: { size, diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/monitor_group_iterator.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/monitor_group_iterator.ts index 1de2dbb0e364d..27c16863a37ab 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/monitor_group_iterator.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/monitor_group_iterator.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; +import { QueryContext } from './query_context'; import { CursorPagination } from '../adapter_types'; import { fetchChunk } from './fetch_chunk'; import { CursorDirection } from '../../../../../common/graphql/types'; @@ -155,7 +155,7 @@ export class MonitorGroupIterator { // Returns a copy of this fetcher that goes backwards from the current position reverse(): MonitorGroupIterator | null { - const reverseContext = Object.assign({}, this.queryContext); + const reverseContext = this.queryContext.clone(); const current = this.getCurrent(); reverseContext.pagination = { diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/query_context.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/query_context.ts new file mode 100644 index 0000000000000..03e228952f0e7 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/query_context.ts @@ -0,0 +1,135 @@ +/* + * 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 DateMath from '@elastic/datemath'; +import { APICaller } from 'kibana/server'; +import { CursorPagination } from '../adapter_types'; +import { INDEX_NAMES } from '../../../../../common/constants'; + +export class QueryContext { + callES: APICaller; + dateRangeStart: string; + dateRangeEnd: string; + pagination: CursorPagination; + filterClause: any | null; + size: number; + statusFilter?: string; + hasTimespanCache?: boolean; + + constructor( + database: any, + dateRangeStart: string, + dateRangeEnd: string, + pagination: CursorPagination, + filterClause: any | null, + size: number, + statusFilter?: string + ) { + this.callES = database; + this.dateRangeStart = dateRangeStart; + this.dateRangeEnd = dateRangeEnd; + this.pagination = pagination; + this.filterClause = filterClause; + this.size = size; + this.statusFilter = statusFilter; + } + + async search(params: any): Promise { + params.index = INDEX_NAMES.HEARTBEAT; + return this.callES('search', params); + } + + async count(params: any): Promise { + params.index = INDEX_NAMES.HEARTBEAT; + return this.callES('count', params); + } + + async dateAndCustomFilters(): Promise { + const clauses = [await this.dateRangeFilter()]; + if (this.filterClause) { + clauses.push(this.filterClause); + } + return clauses; + } + + async dateRangeFilter(forceNoTimespan?: boolean): Promise { + const timestampClause = { + range: { '@timestamp': { gte: this.dateRangeStart, lte: this.dateRangeEnd } }, + }; + + if (forceNoTimespan === true || !(await this.hasTimespan())) { + return timestampClause; + } + + // @ts-ignore + const tsStart = DateMath.parse(this.dateRangeEnd).subtract(10, 'seconds'); + const tsEnd = DateMath.parse(this.dateRangeEnd)!; + + return { + bool: { + filter: [ + timestampClause, + { + bool: { + should: [ + { + range: { + 'monitor.timespan': { + gte: tsStart.toISOString(), + lte: tsEnd.toISOString(), + }, + }, + }, + { + bool: { + must_not: { exists: { field: 'monitor.timespan' } }, + }, + }, + ], + }, + }, + ], + }, + }; + } + + async hasTimespan(): Promise { + if (this.hasTimespanCache) { + return this.hasTimespanCache; + } + + this.hasTimespanCache = + ( + await this.count({ + body: { + query: { + bool: { + filter: [ + await this.dateRangeFilter(true), + { exists: { field: 'monitor.timespan' } }, + ], + }, + }, + }, + terminate_after: 1, + }) + ).count > 0; + + return this.hasTimespanCache; + } + + clone(): QueryContext { + return new QueryContext( + this.callES, + this.dateRangeStart, + this.dateRangeEnd, + this.pagination, + this.filterClause, + this.size, + this.statusFilter + ); + } +} diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/refine_potential_matches.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/refine_potential_matches.ts index b0060cbee17bb..f8347d0737521 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/refine_potential_matches.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/refine_potential_matches.ts @@ -5,10 +5,9 @@ */ import { INDEX_NAMES } from '../../../../../common/constants'; -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; +import { QueryContext } from './query_context'; import { CursorDirection } from '../../../../../common/graphql/types'; import { MonitorGroups, MonitorLocCheckGroup } from './fetch_page'; -import { makeDateRangeFilter } from '../../../helper/make_date_rate_filter'; /** * Determines whether the provided check groups are the latest complete check groups for their associated monitor ID's. @@ -103,7 +102,7 @@ export const mostRecentCheckGroups = async ( query: { bool: { filter: [ - makeDateRangeFilter(queryContext.dateRangeStart, queryContext.dateRangeEnd), + await queryContext.dateRangeFilter(), { terms: { 'monitor.id': potentialMatchMonitorIDs } }, // only match summary docs because we only want the latest *complete* check group. { exists: { field: 'summary' } }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ee33ff5c568aa..916360a46e5a1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11818,9 +11818,9 @@ "xpack.uptime.pingList.expandRow": "拡張", "xpack.uptime.snapshot.pingsOverTimeTitle": "一定時間のピング", "xpack.uptime.snapshotHistogram.yAxis.title": "ピング", - "xpack.uptime.donutChart.ariaLabel": "現在のステータスを表す円グラフ、{total} 個中 {down} 個のモニターがダウンしています。", - "xpack.uptime.donutChart.legend.downRowLabel": "ダウン", - "xpack.uptime.donutChart.legend.upRowLabel": "アップ", + "xpack.uptime.snapshot.donutChart.ariaLabel": "現在のステータスを表す円グラフ、{total} 個中 {down} 個のモニターがダウンしています。", + "xpack.uptime.snapshot.donutChart.legend.downRowLabel": "ダウン", + "xpack.uptime.snapshot.donutChart.legend.upRowLabel": "アップ", "xpack.uptime.durationChart.emptyPrompt.description": "このモニターは選択された時間範囲で一度も {emphasizedText} していません。", "xpack.uptime.durationChart.emptyPrompt.title": "利用可能な期間データがありません", "xpack.uptime.emptyStateError.notAuthorized": "アップタイムデータの表示が承認されていません。システム管理者にお問い合わせください。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ced7c4e822c27..f2806b0b33c29 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11907,9 +11907,9 @@ "xpack.uptime.pingList.expandRow": "展开", "xpack.uptime.snapshot.pingsOverTimeTitle": "时移 Ping 数", "xpack.uptime.snapshotHistogram.yAxis.title": "Ping", - "xpack.uptime.donutChart.ariaLabel": "显示当前状态的饼图。{down} 个监测已关闭,共 {total} 个。", - "xpack.uptime.donutChart.legend.downRowLabel": "关闭", - "xpack.uptime.donutChart.legend.upRowLabel": "运行", + "xpack.uptime.snapshot.donutChart.ariaLabel": "显示当前状态的饼图。{down} 个监测已关闭,共 {total} 个。", + "xpack.uptime.snapshot.donutChart.legend.downRowLabel": "关闭", + "xpack.uptime.snapshot.donutChart.legend.upRowLabel": "运行", "xpack.uptime.durationChart.emptyPrompt.description": "在选定时间范围内此监测从未{emphasizedText}。", "xpack.uptime.durationChart.emptyPrompt.title": "没有持续时间数据", "xpack.uptime.emptyStateError.notAuthorized": "您无权查看 Uptime 数据,请联系系统管理员。", diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot.json index 93d63bad66e30..12d8f514a3a30 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot.json +++ b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot.json @@ -1,6 +1,5 @@ { - "up": 93, + "up": 10, "down": 7, - "mixed": 0, - "total": 100 + "total": 17 } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_empty.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_empty.json index 94c1ffbc74290..c1e7f0ba247fb 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_empty.json +++ b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_empty.json @@ -1,6 +1,5 @@ { "up": 0, - "down": 7, - "mixed": 0, - "total": 7 + "down": 0, + "total": 0 } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_down.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_down.json index 94c1ffbc74290..94777570dd6f0 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_down.json +++ b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_down.json @@ -1,6 +1,5 @@ { "up": 0, "down": 7, - "mixed": 0, "total": 7 } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_up.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_up.json index 2d79880e7c0ee..42a1581707360 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_up.json +++ b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_up.json @@ -1,6 +1,5 @@ { - "up": 93, + "up": 10, "down": 0, - "mixed": 0, - "total": 93 + "total": 10 } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/helpers/make_checks.ts b/x-pack/test/api_integration/apis/uptime/graphql/helpers/make_checks.ts index 9bfdf04c8dbe3..f89905f0da04f 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/helpers/make_checks.ts +++ b/x-pack/test/api_integration/apis/uptime/graphql/helpers/make_checks.ts @@ -5,11 +5,12 @@ */ import uuid from 'uuid'; -import { merge } from 'lodash'; +import { merge, flattenDeep } from 'lodash'; + +const INDEX_NAME = 'heartbeat-8.0.0'; export const makePing = async ( es: any, - index: string, monitorId: string, fields: { [key: string]: any }, mogrify: (doc: any) => any @@ -101,7 +102,7 @@ export const makePing = async ( const doc = mogrify(merge(baseDoc, fields)); await es.index({ - index, + index: INDEX_NAME, refresh: true, body: doc, }); @@ -111,7 +112,6 @@ export const makePing = async ( export const makeCheck = async ( es: any, - index: string, monitorId: string, numIps: number, fields: { [key: string]: any }, @@ -137,7 +137,7 @@ export const makeCheck = async ( if (i === numIps - 1) { pingFields.summary = summary; } - const doc = await makePing(es, index, monitorId, pingFields, mogrify); + const doc = await makePing(es, monitorId, pingFields, mogrify); docs.push(doc); // @ts-ignore summary[doc.monitor.status]++; @@ -147,16 +147,78 @@ export const makeCheck = async ( export const makeChecks = async ( es: any, - index: string, monitorId: string, numChecks: number, numIps: number, + every: number, // number of millis between checks fields: { [key: string]: any } = {}, mogrify: (doc: any) => any = d => d ) => { const checks = []; + const oldestTime = new Date().getTime() - numChecks * every; + let newestTime = oldestTime; for (let li = 0; li < numChecks; li++) { - checks.push(await makeCheck(es, index, monitorId, numIps, fields, mogrify)); + const checkDate = new Date(newestTime + every); + newestTime = checkDate.getTime() + every; + fields = merge(fields, { + '@timestamp': checkDate.toISOString(), + monitor: { + timespan: { + gte: checkDate.toISOString(), + lt: new Date(newestTime).toISOString(), + }, + }, + }); + checks.push(await makeCheck(es, monitorId, numIps, fields, mogrify)); } + return checks; }; + +export const makeChecksWithStatus = async ( + es: any, + monitorId: string, + numChecks: number, + numIps: number, + every: number, + fields: { [key: string]: any } = {}, + status: 'up' | 'down', + mogrify: (doc: any) => any = d => d +) => { + const oppositeStatus = status === 'up' ? 'down' : 'up'; + + return await makeChecks(es, monitorId, numChecks, numIps, every, fields, d => { + d.monitor.status = status; + if (d.summary) { + d.summary[status] += d.summary[oppositeStatus]; + d.summary[oppositeStatus] = 0; + } + + return mogrify(d); + }); +}; + +// Helper for processing a list of checks to find the time picker bounds. +export const getChecksDateRange = (checks: any[]) => { + // Flatten 2d arrays + const flattened = flattenDeep(checks); + + let startTime = 1 / 0; + let endTime = -1 / 0; + flattened.forEach(c => { + const ts = Date.parse(c['@timestamp']); + + if (ts < startTime) { + startTime = ts; + } + + if (ts > endTime) { + endTime = ts; + } + }); + + return { + start: new Date(startTime).toISOString(), + end: new Date(endTime).toISOString(), + }; +}; diff --git a/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts b/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts index c305bb99c28f7..511cdb6d004fa 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts +++ b/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { monitorStatesQueryString } from '../../../../../legacy/plugins/uptime/public/queries/monitor_states_query'; import { expectFixtureEql } from './helpers/expect_fixture_eql'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { makeChecks } from './helpers/make_checks'; +import { makeChecksWithStatus } from './helpers/make_checks'; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -104,11 +104,10 @@ export default function({ getService }: FtrProviderContext) { }; before(async () => { - const index = 'heartbeat-8.0.0'; - const es = getService('legacyEs'); dateRangeStart = new Date().toISOString(); - checks = await makeChecks(es, index, testMonitorId, 1, numIps, {}, d => { + checks = await makeChecksWithStatus(es, testMonitorId, 1, numIps, 1, {}, 'up', d => { + // turn an all up status into having at least one down if (d.summary) { d.monitor.status = 'down'; d.summary.up--; diff --git a/x-pack/test/api_integration/apis/uptime/rest/index.ts b/x-pack/test/api_integration/apis/uptime/rest/index.ts index b76d3f7c2e44a..68377e0820e05 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/index.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/index.ts @@ -9,8 +9,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function({ getService, loadTestFile }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('uptime REST endpoints', () => { - before('load heartbeat data', () => esArchiver.load('uptime/full_heartbeat')); - after('unload', () => esArchiver.unload('uptime/full_heartbeat')); + before('load heartbeat data', () => esArchiver.load('uptime/blank')); + after('unload', () => esArchiver.unload('uptime/blank')); loadTestFile(require.resolve('./snapshot')); }); } diff --git a/x-pack/test/api_integration/apis/uptime/rest/snapshot.ts b/x-pack/test/api_integration/apis/uptime/rest/snapshot.ts index 0175dc649b495..b0d97837c770f 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/snapshot.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/snapshot.ts @@ -6,47 +6,97 @@ import { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql'; import { FtrProviderContext } from '../../../ftr_provider_context'; +import { makeChecksWithStatus, getChecksDateRange } from '../graphql/helpers/make_checks'; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('snapshot count', () => { - let dateRangeStart = '2019-01-28T17:40:08.078Z'; - let dateRangeEnd = '2025-01-28T19:00:16.078Z'; - - it('will fetch the full set of snapshot counts', async () => { - const apiResponse = await supertest.get( - `/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}` - ); - expectFixtureEql(apiResponse.body, 'snapshot'); - }); + const dateRangeStart = new Date().toISOString(); + const dateRangeEnd = new Date().toISOString(); - it('will fetch a monitor snapshot filtered by down status', async () => { - const filters = `{"bool":{"must":[{"match":{"monitor.status":{"query":"down","operator":"and"}}}]}}`; - const statusFilter = 'down'; - const apiResponse = await supertest.get( - `/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}&filters=${filters}&statusFilter=${statusFilter}` - ); - expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_down'); - }); + describe('when no data is present', async () => { + it('returns a null snapshot', async () => { + const apiResponse = await supertest.get( + `/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}` + ); - it('will fetch a monitor snapshot filtered by up status', async () => { - const filters = `{"bool":{"must":[{"match":{"monitor.status":{"query":"up","operator":"and"}}}]}}`; - const statusFilter = 'up'; - const apiResponse = await supertest.get( - `/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}&filters=${filters}&statusFilter=${statusFilter}` - ); - expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_up'); + expectFixtureEql(apiResponse.body, 'snapshot_empty'); + }); }); - it('returns a null snapshot when no data is present', async () => { - dateRangeStart = '2019-01-25T04:30:54.740Z'; - dateRangeEnd = '2025-01-28T04:50:54.740Z'; - const filters = `{"bool":{"must":[{"match":{"monitor.status":{"query":"down","operator":"and"}}}]}}`; - const apiResponse = await supertest.get( - `/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}&filters=${filters}` - ); - expectFixtureEql(apiResponse.body, 'snapshot_empty'); + describe('when data is present', async () => { + const numUpMonitors = 10; + const numDownMonitors = 7; + const numIps = 2; + const checksPerMonitor = 5; + const scheduleEvery = 10000; // fake monitor checks every 10s + let dateRange: { start: string; end: string }; + + [true, false].forEach(async (includeTimespan: boolean) => { + describe(`with timespans ${includeTimespan ? 'included' : 'missing'}`, async () => { + before(async () => { + const promises: Array> = []; + + // When includeTimespan is false we have to remove the values there. + let mogrify = (d: any) => d; + if ((includeTimespan = false)) { + mogrify = (d: any): any => { + d.monitor.delete('timespan'); + return d; + }; + } + + const makeMonitorChecks = async (monitorId: string, status: 'up' | 'down') => { + return makeChecksWithStatus( + getService('legacyEs'), + monitorId, + checksPerMonitor, + numIps, + scheduleEvery, + {}, + status, + mogrify + ); + }; + + for (let i = 0; i < numUpMonitors; i++) { + promises.push(makeMonitorChecks(`up-${i}`, 'up')); + } + for (let i = 0; i < numDownMonitors; i++) { + promises.push(makeMonitorChecks(`down-${i}`, 'down')); + } + + const allResults = await Promise.all(promises); + dateRange = getChecksDateRange(allResults); + }); + + it('will count all statuses correctly', async () => { + const apiResponse = await supertest.get( + `/api/uptime/snapshot/count?dateRangeStart=${dateRange.start}&dateRangeEnd=${dateRange.end}` + ); + + expectFixtureEql(apiResponse.body, 'snapshot'); + }); + + it('will fetch a monitor snapshot filtered by down status', async () => { + const statusFilter = 'down'; + const apiResponse = await supertest.get( + `/api/uptime/snapshot/count?dateRangeStart=${dateRange.start}&dateRangeEnd=${dateRange.end}&statusFilter=${statusFilter}` + ); + + expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_down'); + }); + + it('will fetch a monitor snapshot filtered by up status', async () => { + const statusFilter = 'up'; + const apiResponse = await supertest.get( + `/api/uptime/snapshot/count?dateRangeStart=${dateRange.start}&dateRangeEnd=${dateRange.end}&statusFilter=${statusFilter}` + ); + expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_up'); + }); + }); + }); }); }); } diff --git a/x-pack/test/functional/apps/uptime/overview.ts b/x-pack/test/functional/apps/uptime/overview.ts index 9a337c86185fe..0366d12808370 100644 --- a/x-pack/test/functional/apps/uptime/overview.ts +++ b/x-pack/test/functional/apps/uptime/overview.ts @@ -64,5 +64,27 @@ export default ({ getPageObjects }: FtrProviderContext) => { '0009-up', ]); }); + + describe('snapshot counts', () => { + it('updates the snapshot count when status filter is set to down', async () => { + await pageObjects.uptime.goToUptimePageAndSetDateRange( + DEFAULT_DATE_START, + DEFAULT_DATE_END + ); + await pageObjects.uptime.setStatusFilter('down'); + const counts = await pageObjects.uptime.getSnapshotCount(); + expect(counts).to.eql({ up: '0', down: '7' }); + }); + + it('updates the snapshot count when status filter is set to up', async () => { + await pageObjects.uptime.goToUptimePageAndSetDateRange( + DEFAULT_DATE_START, + DEFAULT_DATE_END + ); + await pageObjects.uptime.setStatusFilter('up'); + const counts = await pageObjects.uptime.getSnapshotCount(); + expect(counts).to.eql({ up: '93', down: '0' }); + }); + }); }); }; diff --git a/x-pack/test/functional/es_archives/uptime/blank/mappings.json b/x-pack/test/functional/es_archives/uptime/blank/mappings.json index eb383cc87563d..a1b0696cdaadc 100644 --- a/x-pack/test/functional/es_archives/uptime/blank/mappings.json +++ b/x-pack/test/functional/es_archives/uptime/blank/mappings.json @@ -1267,6 +1267,9 @@ "type": { "ignore_above": 1024, "type": "keyword" + }, + "timespan": { + "type": "date_range" } } }, diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index 26c95c3bf526d..6018b7ceebfa5 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -70,5 +70,9 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo await uptimeService.setStatusFilterDown(); } } + + public async getSnapshotCount() { + return await uptimeService.getSnapshotCount(); + } })(); } diff --git a/x-pack/test/functional/services/uptime.ts b/x-pack/test/functional/services/uptime.ts index 40d2e3dafc7f8..12f1a23f1e024 100644 --- a/x-pack/test/functional/services/uptime.ts +++ b/x-pack/test/functional/services/uptime.ts @@ -49,5 +49,11 @@ export function UptimeProvider({ getService }: FtrProviderContext) { async setStatusFilterDown() { await testSubjects.click('xpack.uptime.filterBar.filterStatusDown'); }, + async getSnapshotCount() { + return { + up: await testSubjects.getVisibleText('xpack.uptime.snapshot.donutChart.up'), + down: await testSubjects.getVisibleText('xpack.uptime.snapshot.donutChart.down'), + }; + }, }; } From 12bfd3bf779dcfea9d95124ca1cde13be87f224e Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Thu, 9 Jan 2020 09:48:22 -0500 Subject: [PATCH 38/63] Fix number of executions per agent in flaky test job (#54311) --- .ci/Jenkinsfile_flaky | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/Jenkinsfile_flaky b/.ci/Jenkinsfile_flaky index 669395564db44..f702405aad69e 100644 --- a/.ci/Jenkinsfile_flaky +++ b/.ci/Jenkinsfile_flaky @@ -99,7 +99,7 @@ def getWorkerMap(agentNumber, numberOfExecutions, worker, workerFailures, maxWor def numberOfWorkers = Math.min(numberOfExecutions, maxWorkerProcesses) for(def i = 1; i <= numberOfWorkers; i++) { - def workerExecutions = numberOfExecutions/numberOfWorkers + (i <= numberOfExecutions%numberOfWorkers ? 1 : 0) + def workerExecutions = floor(numberOfExecutions/numberOfWorkers + (i <= numberOfExecutions%numberOfWorkers ? 1 : 0)) workerMap["agent-${agentNumber}-worker-${i}"] = { workerNumber -> for(def j = 0; j < workerExecutions; j++) { From 9befff1236b6c3a53449191fbf1df9d9e240fce7 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Thu, 9 Jan 2020 09:03:50 -0600 Subject: [PATCH 39/63] [APM] Fix service map license check and controls (#54286) * Check for a trial license as well as platinum when loading the map * Increase the z-index of the controls so clicking on them works * Rename the styled component to `ControlsContainer` from `Container` to make a less ambiguous class name on the element --- .../apm/public/components/app/ServiceMap/Controls.tsx | 7 ++++--- .../plugins/apm/public/components/app/ServiceMap/index.tsx | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Controls.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Controls.tsx index f7166bd0cec5c..5b7ddc2b450d6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Controls.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Controls.tsx @@ -12,10 +12,11 @@ import { i18n } from '@kbn/i18n'; import { CytoscapeContext } from './Cytoscape'; import { FullscreenPanel } from './FullscreenPanel'; -const Container = styled('div')` +const ControlsContainer = styled('div')` left: ${theme.gutterTypes.gutterMedium}; position: absolute; top: ${theme.gutterTypes.gutterSmall}; + z-index: 1; /* The element containing the cytoscape canvas has z-index = 0. */ `; const Button = styled(EuiButtonIcon)` @@ -83,7 +84,7 @@ export function Controls() { }); return ( - + - + ); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index 16a91116ae762..cc09975a344b5 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -57,7 +57,8 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { const elements = Array.isArray(data) ? data : []; const license = useLicense(); const isValidPlatinumLicense = - license?.isActive && license?.type === 'platinum'; + license?.isActive && + (license?.type === 'platinum' || license?.type === 'trial'); return isValidPlatinumLicense ? ( Date: Thu, 9 Jan 2020 15:21:40 +0000 Subject: [PATCH 40/63] [ML] Categorization wizard (#53009) * [ML] Categorization wizard * fixing js prettier issues * adding basic category field validation * adding rare or count selection * fixing types * category examples changes * improving results search * adding analyzer editing * improving callout * updating callout text * fixing import path * resetting cat analyser json on flyout open * disabling model plot by default * minor refactoring * fixing types * hide estimate bucket span * setting default bucket span * removing ml_classic workaround * changing style of detector selection * fixing convert to advanced issue * removing sparse data checkbox * changes based on review * use default mml * fixing job cloning * changes based on review * removing categorization_analyzer from job if it is same as default * fixing translations * disabling model plot for rare jobs * removing console.error in useResolver --- .../plugins/ml/common/constants/new_job.ts | 8 + .../plugins/ml/common/types/categories.ts | 25 ++ .../job_creator/categorization_job_creator.ts | 159 ++++++++++ .../jobs/new_job/common/job_creator/index.ts | 2 + .../common/job_creator/job_creator_factory.ts | 4 + .../new_job/common/job_creator/type_guards.ts | 10 +- .../common/job_creator/util/general.ts | 9 +- .../common/job_validator/job_validator.ts | 26 +- .../categorization_examples_loader.ts | 57 ++++ .../new_job/common/results_loader/index.ts | 1 + .../charts/anomaly_chart/anomaly_chart.tsx | 2 +- .../{anomaly_chart => common}/anomalies.tsx | 0 .../components/charts/common/settings.ts | 1 + .../event_rate_chart/event_rate_chart.tsx | 13 +- .../loading_wrapper/loading_wrapper.tsx | 4 +- .../edit_categorization_analyzer_flyout.tsx | 147 +++++++++ .../index.ts | 6 + .../model_plot/model_plot_switch.tsx | 16 +- .../components/advanced_view/settings.tsx | 16 +- .../components/bucket_span/bucket_span.tsx | 11 +- .../categorization_detector.tsx | 64 ++++ .../detector_cards.tsx | 59 ++++ .../categorization_detector/index.ts | 6 + .../categorization_field.tsx | 6 +- .../categorization_view.tsx | 42 +++ .../examples_valid_callout.tsx | 112 +++++++ .../categorization_view/field_examples.tsx | 65 ++++ .../components/categorization_view/index.ts | 7 + .../categorization_view/metric_selection.tsx | 108 +++++++ .../metric_selection_summary.tsx | 78 +++++ .../categorization_view/settings.tsx | 26 ++ .../categorization_view/top_categories.tsx | 89 ++++++ .../components/multi_metric_view/settings.tsx | 16 +- .../components/population_view/settings.tsx | 16 +- .../single_metric_view/settings.tsx | 15 +- .../pick_fields_step/pick_fields.tsx | 8 +- .../detector_chart/detector_chart.tsx | 2 + .../preconfigured_job_redirect.ts | 25 +- .../jobs/new_job/pages/job_type/page.tsx | 16 + .../jobs/new_job/pages/new_job/page.tsx | 31 +- .../jobs/new_job/utils/new_job_utils.ts | 10 +- .../routing/routes/new_job/wizard.tsx | 18 ++ .../application/routing/use_resolver.ts | 2 - .../services/ml_api_service/index.d.ts | 17 +- .../services/ml_api_service/jobs.js | 37 +++ .../application/services/ml_server_info.ts | 8 + .../services/new_job_capabilities_service.ts | 3 +- .../ml/public/application/util/index_utils.ts | 8 +- .../ml/server/client/elasticsearch_ml.js | 25 ++ .../ml/server/models/job_service/index.js | 3 +- .../job_service/new_job/categorization.ts | 294 ++++++++++++++++++ .../models/job_service/new_job/charts.ts | 2 +- .../models/job_service/new_job/index.ts | 1 + .../models/job_service/new_job/line_chart.ts | 3 +- .../job_service/new_job/population_chart.ts | 3 +- .../ml/server/routes/anomaly_detectors.js | 18 ++ .../plugins/ml/server/routes/job_service.js | 46 +++ 57 files changed, 1702 insertions(+), 104 deletions(-) create mode 100644 x-pack/legacy/plugins/ml/common/types/categories.ts create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts rename x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/{anomaly_chart => common}/anomalies.tsx (100%) create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/edit_categorization_analyzer_flyout.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/index.ts create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/categorization_detector.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/detector_cards.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/index.ts create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/categorization_view.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/examples_valid_callout.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/field_examples.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/index.ts create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/settings.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx create mode 100644 x-pack/legacy/plugins/ml/server/models/job_service/new_job/categorization.ts diff --git a/x-pack/legacy/plugins/ml/common/constants/new_job.ts b/x-pack/legacy/plugins/ml/common/constants/new_job.ts index 004824bef1c9d..ccd108cd2698f 100644 --- a/x-pack/legacy/plugins/ml/common/constants/new_job.ts +++ b/x-pack/legacy/plugins/ml/common/constants/new_job.ts @@ -9,16 +9,24 @@ export enum JOB_TYPE { MULTI_METRIC = 'multi_metric', POPULATION = 'population', ADVANCED = 'advanced', + CATEGORIZATION = 'categorization', } export enum CREATED_BY_LABEL { SINGLE_METRIC = 'single-metric-wizard', MULTI_METRIC = 'multi-metric-wizard', POPULATION = 'population-wizard', + CATEGORIZATION = 'categorization-wizard', } export const DEFAULT_MODEL_MEMORY_LIMIT = '10MB'; export const DEFAULT_BUCKET_SPAN = '15m'; +export const DEFAULT_RARE_BUCKET_SPAN = '1h'; export const DEFAULT_QUERY_DELAY = '60s'; export const SHARED_RESULTS_INDEX_NAME = 'shared'; + +export const NUMBER_OF_CATEGORY_EXAMPLES = 5; +export const CATEGORY_EXAMPLES_MULTIPLIER = 20; +export const CATEGORY_EXAMPLES_WARNING_LIMIT = 0.75; +export const CATEGORY_EXAMPLES_ERROR_LIMIT = 0.2; diff --git a/x-pack/legacy/plugins/ml/common/types/categories.ts b/x-pack/legacy/plugins/ml/common/types/categories.ts new file mode 100644 index 0000000000000..6ccd13ed9a39e --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/types/categories.ts @@ -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. + */ + +export type CategoryId = number; + +export interface Category { + job_id: string; + category_id: CategoryId; + terms: string; + regex: string; + max_matching_length: number; + examples: string[]; + grok_pattern: string; +} + +export interface Token { + token: string; + start_offset: number; + end_offset: number; + type: string; + position: number; +} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts new file mode 100644 index 0000000000000..cea99eb5ec64c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts @@ -0,0 +1,159 @@ +/* + * 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 { isEqual } from 'lodash'; +import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { SavedSearchSavedObject } from '../../../../../../common/types/kibana'; +import { JobCreator } from './job_creator'; +import { Field, Aggregation, mlCategory } from '../../../../../../common/types/fields'; +import { Job, Datafeed, Detector } from './configs'; +import { createBasicDetector } from './util/default_configs'; +import { + JOB_TYPE, + CREATED_BY_LABEL, + DEFAULT_BUCKET_SPAN, + DEFAULT_RARE_BUCKET_SPAN, +} from '../../../../../../common/constants/new_job'; +import { ML_JOB_AGGREGATION } from '../../../../../../common/constants/aggregation_types'; +import { getRichDetectors } from './util/general'; +import { CategorizationExamplesLoader, CategoryExample } from '../results_loader'; +import { CategorizationAnalyzer, getNewJobDefaults } from '../../../../services/ml_server_info'; + +type CategorizationAnalyzerType = CategorizationAnalyzer | null; + +export class CategorizationJobCreator extends JobCreator { + protected _type: JOB_TYPE = JOB_TYPE.CATEGORIZATION; + private _createCountDetector: () => void = () => {}; + private _createRareDetector: () => void = () => {}; + private _examplesLoader: CategorizationExamplesLoader; + private _categoryFieldExamples: CategoryExample[] = []; + private _categoryFieldValid: number = 0; + private _detectorType: ML_JOB_AGGREGATION.COUNT | ML_JOB_AGGREGATION.RARE = + ML_JOB_AGGREGATION.COUNT; + private _categorizationAnalyzer: CategorizationAnalyzerType = null; + private _defaultCategorizationAnalyzer: CategorizationAnalyzerType; + + constructor( + indexPattern: IndexPattern, + savedSearch: SavedSearchSavedObject | null, + query: object + ) { + super(indexPattern, savedSearch, query); + this.createdBy = CREATED_BY_LABEL.CATEGORIZATION; + this._examplesLoader = new CategorizationExamplesLoader(this, indexPattern, query); + + const { anomaly_detectors: anomalyDetectors } = getNewJobDefaults(); + this._defaultCategorizationAnalyzer = anomalyDetectors.categorization_analyzer || null; + } + + public setDefaultDetectorProperties( + count: Aggregation | null, + rare: Aggregation | null, + eventRate: Field | null + ) { + if (count === null || rare === null || eventRate === null) { + return; + } + + this._createCountDetector = () => { + this._createDetector(count, eventRate); + }; + this._createRareDetector = () => { + this._createDetector(rare, eventRate); + }; + } + + private _createDetector(agg: Aggregation, field: Field) { + const dtr: Detector = createBasicDetector(agg, field); + dtr.by_field_name = mlCategory.id; + this._addDetector(dtr, agg, mlCategory); + } + + public setDetectorType(type: ML_JOB_AGGREGATION.COUNT | ML_JOB_AGGREGATION.RARE) { + this._detectorType = type; + this.removeAllDetectors(); + if (type === ML_JOB_AGGREGATION.COUNT) { + this._createCountDetector(); + this.bucketSpan = DEFAULT_BUCKET_SPAN; + } else { + this._createRareDetector(); + this.bucketSpan = DEFAULT_RARE_BUCKET_SPAN; + this.modelPlot = false; + } + } + + public set categorizationFieldName(fieldName: string | null) { + if (fieldName !== null) { + this._job_config.analysis_config.categorization_field_name = fieldName; + this.setDetectorType(this._detectorType); + this.addInfluencer(mlCategory.id); + } else { + delete this._job_config.analysis_config.categorization_field_name; + this._categoryFieldExamples = []; + this._categoryFieldValid = 0; + } + } + + public get categorizationFieldName(): string | null { + return this._job_config.analysis_config.categorization_field_name || null; + } + + public async loadCategorizationFieldExamples() { + const { valid, examples } = await this._examplesLoader.loadExamples(); + this._categoryFieldExamples = examples; + this._categoryFieldValid = valid; + return { valid, examples }; + } + + public get categoryFieldExamples() { + return this._categoryFieldExamples; + } + + public get categoryFieldValid() { + return this._categoryFieldValid; + } + + public get selectedDetectorType() { + return this._detectorType; + } + + public set categorizationAnalyzer(analyzer: CategorizationAnalyzerType) { + this._categorizationAnalyzer = analyzer; + + if ( + analyzer === null || + isEqual(this._categorizationAnalyzer, this._defaultCategorizationAnalyzer) + ) { + delete this._job_config.analysis_config.categorization_analyzer; + } else { + this._job_config.analysis_config.categorization_analyzer = analyzer; + } + } + + public get categorizationAnalyzer() { + return this._categorizationAnalyzer; + } + + public cloneFromExistingJob(job: Job, datafeed: Datafeed) { + this._overrideConfigs(job, datafeed); + this.createdBy = CREATED_BY_LABEL.CATEGORIZATION; + const detectors = getRichDetectors(job, datafeed, this.scriptFields, false); + + const dtr = detectors[0]; + if (detectors.length && dtr.agg !== null && dtr.field !== null) { + this._detectorType = + dtr.agg.id === ML_JOB_AGGREGATION.COUNT + ? ML_JOB_AGGREGATION.COUNT + : ML_JOB_AGGREGATION.RARE; + + const bs = job.analysis_config.bucket_span; + this.setDetectorType(this._detectorType); + // set the bucketspan back to the original value + // as setDetectorType applies a default + this.bucketSpan = bs; + } + } +} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/index.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/index.ts index 8422223ad91fb..88bacdf49c38a 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/index.ts @@ -9,11 +9,13 @@ export { SingleMetricJobCreator } from './single_metric_job_creator'; export { MultiMetricJobCreator } from './multi_metric_job_creator'; export { PopulationJobCreator } from './population_job_creator'; export { AdvancedJobCreator } from './advanced_job_creator'; +export { CategorizationJobCreator } from './categorization_job_creator'; export { JobCreatorType, isSingleMetricJobCreator, isMultiMetricJobCreator, isPopulationJobCreator, isAdvancedJobCreator, + isCategorizationJobCreator, } from './type_guards'; export { jobCreatorFactory } from './job_creator_factory'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator_factory.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator_factory.ts index 868346d0188ea..8655b83a244ad 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator_factory.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator_factory.ts @@ -10,6 +10,7 @@ import { MultiMetricJobCreator } from './multi_metric_job_creator'; import { PopulationJobCreator } from './population_job_creator'; import { AdvancedJobCreator } from './advanced_job_creator'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { CategorizationJobCreator } from './categorization_job_creator'; import { JOB_TYPE } from '../../../../../../common/constants/new_job'; @@ -32,6 +33,9 @@ export const jobCreatorFactory = (jobType: JOB_TYPE) => ( case JOB_TYPE.ADVANCED: jc = AdvancedJobCreator; break; + case JOB_TYPE.CATEGORIZATION: + jc = CategorizationJobCreator; + break; default: jc = SingleMetricJobCreator; break; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/type_guards.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/type_guards.ts index 9feb0416dd267..25ea80e18eeb3 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/type_guards.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/type_guards.ts @@ -8,13 +8,15 @@ import { SingleMetricJobCreator } from './single_metric_job_creator'; import { MultiMetricJobCreator } from './multi_metric_job_creator'; import { PopulationJobCreator } from './population_job_creator'; import { AdvancedJobCreator } from './advanced_job_creator'; +import { CategorizationJobCreator } from './categorization_job_creator'; import { JOB_TYPE } from '../../../../../../common/constants/new_job'; export type JobCreatorType = | SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator - | AdvancedJobCreator; + | AdvancedJobCreator + | CategorizationJobCreator; export function isSingleMetricJobCreator( jobCreator: JobCreatorType @@ -37,3 +39,9 @@ export function isPopulationJobCreator( export function isAdvancedJobCreator(jobCreator: JobCreatorType): jobCreator is AdvancedJobCreator { return jobCreator.type === JOB_TYPE.ADVANCED; } + +export function isCategorizationJobCreator( + jobCreator: JobCreatorType +): jobCreator is CategorizationJobCreator { + return jobCreator.type === JOB_TYPE.CATEGORIZATION; +} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 29af08b8826ee..22b727452dd8d 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -19,7 +19,12 @@ import { mlCategory, } from '../../../../../../../common/types/fields'; import { mlJobService } from '../../../../../services/job_service'; -import { JobCreatorType, isMultiMetricJobCreator, isPopulationJobCreator } from '../index'; +import { + JobCreatorType, + isMultiMetricJobCreator, + isPopulationJobCreator, + isCategorizationJobCreator, +} from '../index'; import { CREATED_BY_LABEL, JOB_TYPE } from '../../../../../../../common/constants/new_job'; const getFieldByIdFactory = (scriptFields: Field[]) => (id: string) => { @@ -251,6 +256,8 @@ export function convertToAdvancedJob(jobCreator: JobCreatorType) { jobType = JOB_TYPE.MULTI_METRIC; } else if (isPopulationJobCreator(jobCreator)) { jobType = JOB_TYPE.POPULATION; + } else if (isCategorizationJobCreator(jobCreator)) { + jobType = JOB_TYPE.CATEGORIZATION; } window.location.href = window.location.href.replace(jobType, JOB_TYPE.ADVANCED); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts index 3c1f767aeaf9c..976e94b377ae8 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts @@ -12,10 +12,11 @@ import { basicDatafeedValidation, } from '../../../../../../common/util/job_utils'; import { getNewJobLimits } from '../../../../services/ml_server_info'; -import { JobCreator, JobCreatorType } from '../job_creator'; +import { JobCreator, JobCreatorType, isCategorizationJobCreator } from '../job_creator'; import { populateValidationMessages, checkForExistingJobAndGroupIds } from './util'; import { ExistingJobsAndGroups } from '../../../../services/job_service'; import { cardinalityValidator, CardinalityValidatorResult } from './validators'; +import { CATEGORY_EXAMPLES_ERROR_LIMIT } from '../../../../../../common/constants/new_job'; // delay start of validation to allow the user to make changes // e.g. if they are typing in a new value, try not to validate @@ -51,6 +52,10 @@ export interface BasicValidations { scrollSize: Validation; } +export interface AdvancedValidations { + categorizationFieldValid: Validation; +} + export class JobValidator { private _jobCreator: JobCreatorType; private _validationSummary: ValidationSummary; @@ -71,6 +76,9 @@ export class JobValidator { frequency: { valid: true }, scrollSize: { valid: true }, }; + private _advancedValidations: AdvancedValidations = { + categorizationFieldValid: { valid: true }, + }; private _validating: boolean = false; private _basicValidationResult$ = new ReplaySubject(2); @@ -141,6 +149,7 @@ export class JobValidator { this._lastDatafeedConfig = formattedDatafeedConfig; this._validateTimeout = setTimeout(() => { this._runBasicValidation(); + this._runAdvancedValidation(); this._jobCreatorSubject$.next(this._jobCreator); @@ -195,6 +204,13 @@ export class JobValidator { this._basicValidationResult$.next(this._basicValidations); } + private _runAdvancedValidation() { + if (isCategorizationJobCreator(this._jobCreator)) { + this._advancedValidations.categorizationFieldValid.valid = + this._jobCreator.categoryFieldValid > CATEGORY_EXAMPLES_ERROR_LIMIT; + } + } + private _isOverallBasicValid() { return Object.values(this._basicValidations).some(v => v.valid === false) === false; } @@ -246,4 +262,12 @@ export class JobValidator { public get validating(): boolean { return this._validating; } + + public get categorizationField() { + return this._advancedValidations.categorizationFieldValid.valid; + } + + public set categorizationField(valid: boolean) { + this._advancedValidations.categorizationFieldValid.valid = valid; + } } diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts new file mode 100644 index 0000000000000..16f127ae3d728 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts @@ -0,0 +1,57 @@ +/* + * 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 { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { IndexPatternTitle } from '../../../../../../common/types/kibana'; +import { Token } from '../../../../../../common/types/categories'; +import { CategorizationJobCreator } from '../job_creator'; +import { ml } from '../../../../services/ml_api_service'; +import { NUMBER_OF_CATEGORY_EXAMPLES } from '../../../../../../common/constants/new_job'; + +export interface CategoryExample { + text: string; + tokens: Token[]; +} + +export class CategorizationExamplesLoader { + private _jobCreator: CategorizationJobCreator; + private _indexPatternTitle: IndexPatternTitle = ''; + private _timeFieldName: string = ''; + private _query: object = {}; + + constructor(jobCreator: CategorizationJobCreator, indexPattern: IndexPattern, query: object) { + this._jobCreator = jobCreator; + this._indexPatternTitle = indexPattern.title; + this._query = query; + + if (typeof indexPattern.timeFieldName === 'string') { + this._timeFieldName = indexPattern.timeFieldName; + } + } + + public async loadExamples() { + const analyzer = this._jobCreator.categorizationAnalyzer; + const categorizationFieldName = this._jobCreator.categorizationFieldName; + if (categorizationFieldName === null) { + return { valid: 0, examples: [] }; + } + + const start = Math.floor( + this._jobCreator.start + (this._jobCreator.end - this._jobCreator.start) / 2 + ); + const resp = await ml.jobs.categorizationFieldExamples( + this._indexPatternTitle, + this._query, + NUMBER_OF_CATEGORY_EXAMPLES, + categorizationFieldName, + this._timeFieldName, + start, + 0, + analyzer + ); + return resp; + } +} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/index.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/index.ts index ef0b05f73fa31..724c62f22e469 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/index.ts @@ -5,3 +5,4 @@ */ export { ResultsLoader, Results, ModelItem, Anomaly } from './results_loader'; +export { CategorizationExamplesLoader, CategoryExample } from './categorization_examples_loader'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomaly_chart.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomaly_chart.tsx index 728229fc3091d..e519b86278ed8 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomaly_chart.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomaly_chart.tsx @@ -7,7 +7,7 @@ import React, { FC } from 'react'; import { Chart, Settings, TooltipType } from '@elastic/charts'; import { ModelItem, Anomaly } from '../../../../common/results_loader'; -import { Anomalies } from './anomalies'; +import { Anomalies } from '../common/anomalies'; import { ModelBounds } from './model_bounds'; import { Line } from './line'; import { Scatter } from './scatter'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomalies.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/anomalies.tsx similarity index 100% rename from x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomalies.tsx rename to x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/anomalies.tsx diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/settings.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/settings.ts index b1852cbb259c1..7dec882429dce 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/settings.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/settings.ts @@ -20,6 +20,7 @@ const themeName = IS_DARK_THEME ? darkTheme : lightTheme; export const LINE_COLOR = themeName.euiColorPrimary; export const MODEL_COLOR = themeName.euiColorPrimary; export const EVENT_RATE_COLOR = themeName.euiColorPrimary; +export const EVENT_RATE_COLOR_WITH_ANOMALIES = themeName.euiColorLightShade; export interface ChartSettings { width: string; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx index 5423e80a82f15..ddbeb3f0f5b04 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx @@ -8,24 +8,32 @@ import React, { FC } from 'react'; import { BarSeries, Chart, ScaleType, Settings, TooltipType } from '@elastic/charts'; import { Axes } from '../common/axes'; import { LineChartPoint } from '../../../../common/chart_loader'; -import { EVENT_RATE_COLOR } from '../common/settings'; +import { Anomaly } from '../../../../common/results_loader'; +import { EVENT_RATE_COLOR, EVENT_RATE_COLOR_WITH_ANOMALIES } from '../common/settings'; import { LoadingWrapper } from '../loading_wrapper'; +import { Anomalies } from '../common/anomalies'; interface Props { eventRateChartData: LineChartPoint[]; + anomalyData?: Anomaly[]; height: string; width: string; showAxis?: boolean; loading?: boolean; + fadeChart?: boolean; } export const EventRateChart: FC = ({ eventRateChartData, + anomalyData, height, width, showAxis, loading = false, + fadeChart, }) => { + const barColor = fadeChart ? EVENT_RATE_COLOR_WITH_ANOMALIES : EVENT_RATE_COLOR; + return (
= ({ {showAxis === true && } + = ({ xAccessor={'time'} yAccessors={['value']} data={eventRateChartData} - customSeriesColors={[EVENT_RATE_COLOR]} + customSeriesColors={[barColor]} /> diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx index 1087fdc8d0fce..b8600489a4bd9 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx @@ -9,7 +9,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; interface Props { hasData: boolean; - height: string; + height?: string; loading?: boolean; } @@ -31,7 +31,7 @@ export const LoadingWrapper: FC = ({ hasData, loading = false, height, ch diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/edit_categorization_analyzer_flyout.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/edit_categorization_analyzer_flyout.tsx new file mode 100644 index 0000000000000..a44cbf3d0c71a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/edit_categorization_analyzer_flyout.tsx @@ -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 React, { Fragment, FC, useEffect, useState, useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlyout, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiTitle, + EuiFlyoutBody, + EuiSpacer, +} from '@elastic/eui'; +import { MLJobEditor } from '../../../../../jobs_list/components/ml_job_editor'; +import { isValidJson } from '../../../../../../../../common/util/validation_utils'; +import { JobCreatorContext } from '../../job_creator_context'; +import { CategorizationJobCreator } from '../../../../common/job_creator'; +import { getNewJobDefaults } from '../../../../../../services/ml_server_info'; + +const EDITOR_HEIGHT = '800px'; + +export const EditCategorizationAnalyzerFlyout: FC = () => { + const { jobCreator: jc, jobCreatorUpdate } = useContext(JobCreatorContext); + const jobCreator = jc as CategorizationJobCreator; + const [showJsonFlyout, setShowJsonFlyout] = useState(false); + const [saveable, setSaveable] = useState(false); + + const [categorizationAnalyzerString, setCategorizationAnalyzerString] = useState( + JSON.stringify(jobCreator.categorizationAnalyzer, null, 2) + ); + + useEffect(() => { + if (showJsonFlyout === true) { + setCategorizationAnalyzerString(JSON.stringify(jobCreator.categorizationAnalyzer, null, 2)); + } + }, [showJsonFlyout]); + + function toggleJsonFlyout() { + setSaveable(false); + setShowJsonFlyout(!showJsonFlyout); + } + + function onJSONChange(json: string) { + setCategorizationAnalyzerString(json); + const valid = isValidJson(json); + setSaveable(valid); + } + + function onSave() { + jobCreator.categorizationAnalyzer = JSON.parse(categorizationAnalyzerString); + jobCreatorUpdate(); + setShowJsonFlyout(false); + } + + function onUseDefault() { + const { anomaly_detectors: anomalyDetectors } = getNewJobDefaults(); + const analyzerString = JSON.stringify(anomalyDetectors.categorization_analyzer!, null, 2); + onJSONChange(analyzerString); + } + + return ( + + + + {showJsonFlyout === true && ( + setShowJsonFlyout(false)} hideCloseButton size="m"> + + + + + + + setShowJsonFlyout(false)} + flush="left" + > + + + + + + + + + + + + + + + + + + )} + + ); +}; + +const FlyoutButton: FC<{ onClick(): void }> = ({ onClick }) => { + return ( + + + + ); +}; + +const Contents: FC<{ + title: string; + value: string; + onChange(s: string): void; +}> = ({ title, value, onChange }) => { + return ( + + +
{title}
+
+ + +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/index.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/index.ts new file mode 100644 index 0000000000000..5bc89a695b6c8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/index.ts @@ -0,0 +1,6 @@ +/* + * 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 { EditCategorizationAnalyzerFlyout } from './edit_categorization_analyzer_flyout'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx index 64f9f450ae08d..a034bdcc2900f 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx @@ -10,16 +10,29 @@ import { EuiSpacer, EuiSwitch } from '@elastic/eui'; import { JobCreatorContext } from '../../../../../job_creator_context'; import { Description } from './description'; import { MMLCallout } from '../mml_callout'; +import { ML_JOB_AGGREGATION } from '../../../../../../../../../../../common/constants/aggregation_types'; +import { isCategorizationJobCreator } from '../../../../../../../common/job_creator'; export const ModelPlotSwitch: FC = () => { - const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); const [modelPlotEnabled, setModelPlotEnabled] = useState(jobCreator.modelPlot); + const [enabled, setEnabled] = useState(false); useEffect(() => { jobCreator.modelPlot = modelPlotEnabled; jobCreatorUpdate(); }, [modelPlotEnabled]); + useEffect(() => { + const aggs = [ML_JOB_AGGREGATION.RARE]; + // disable model plot switch if the wizard is creating a categorization job + // and a rare detector is being used. + const isRareCategoryJob = + isCategorizationJobCreator(jobCreator) && + jobCreator.aggregations.some(agg => aggs.includes(agg.id)); + setEnabled(isRareCategoryJob === false); + }, [jobCreatorUpdated]); + function toggleModelPlot() { setModelPlotEnabled(!modelPlotEnabled); } @@ -29,6 +42,7 @@ export const ModelPlotSwitch: FC = () => { = ({ setIsValid }) => { - const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); - const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); - - useEffect(() => { - jobCreator.bucketSpan = bucketSpan; - jobCreatorUpdate(); - setIsValid(bucketSpan !== ''); - }, [bucketSpan]); - - useEffect(() => { - setBucketSpan(jobCreator.bucketSpan); - }, [jobCreatorUpdated]); - return ( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx index dfe9272984b81..216561bac2c62 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx @@ -14,9 +14,10 @@ import { BucketSpanEstimator } from '../bucket_span_estimator'; interface Props { setIsValid: (proceed: boolean) => void; + hideEstimateButton?: boolean; } -export const BucketSpan: FC = ({ setIsValid }) => { +export const BucketSpan: FC = ({ setIsValid, hideEstimateButton = false }) => { const { jobCreator, jobCreatorUpdate, @@ -56,9 +57,11 @@ export const BucketSpan: FC = ({ setIsValid }) => { disabled={estimating} />
- - - + {hideEstimateButton === false && ( + + + + )}
); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/categorization_detector.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/categorization_detector.tsx new file mode 100644 index 0000000000000..96ac1c3f0e325 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/categorization_detector.tsx @@ -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 React, { FC, useContext, useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiSpacer, EuiTitle } from '@elastic/eui'; + +import { ML_JOB_AGGREGATION } from '../../../../../../../../../common/constants/aggregation_types'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { CategorizationJobCreator } from '../../../../../common/job_creator'; +import { CountCard, RareCard } from './detector_cards'; + +export const CategorizationDetector: FC = () => { + const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const jobCreator = jc as CategorizationJobCreator; + const [categorizationDetectorType, setCategorizationDetectorType] = useState( + jobCreator.selectedDetectorType + ); + + useEffect(() => { + if (categorizationDetectorType !== jobCreator.selectedDetectorType) { + jobCreator.setDetectorType(categorizationDetectorType); + jobCreatorUpdate(); + } + }, [categorizationDetectorType]); + + useEffect(() => { + setCategorizationDetectorType(jobCreator.selectedDetectorType); + }, [jobCreatorUpdated]); + + function onCountSelection() { + setCategorizationDetectorType(ML_JOB_AGGREGATION.COUNT); + } + function onRareSelection() { + setCategorizationDetectorType(ML_JOB_AGGREGATION.RARE); + } + + return ( + <> + +

+ +

+
+ + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/detector_cards.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/detector_cards.tsx new file mode 100644 index 0000000000000..68d5fc24a96e3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/detector_cards.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. + */ + +import React, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexItem, EuiCard } from '@elastic/eui'; + +interface CardProps { + onClick: () => void; + isSelected: boolean; +} + +export const CountCard: FC = ({ onClick, isSelected }) => ( + + + + + } + selectable={{ onClick, isSelected }} + /> + +); + +export const RareCard: FC = ({ onClick, isSelected }) => ( + + + + + } + selectable={{ onClick, isSelected }} + /> + +); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/index.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/index.ts new file mode 100644 index 0000000000000..6a13d86d0db9a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/index.ts @@ -0,0 +1,6 @@ +/* + * 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 { CategorizationDetector } from './categorization_detector'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx index f9edf79364c97..015300debb156 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx @@ -25,8 +25,10 @@ export const CategorizationField: FC = () => { ); useEffect(() => { - jobCreator.categorizationFieldName = categorizationFieldName; - jobCreatorUpdate(); + if (jobCreator.categorizationFieldName !== categorizationFieldName) { + jobCreator.categorizationFieldName = categorizationFieldName; + jobCreatorUpdate(); + } }, [categorizationFieldName]); useEffect(() => { diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/categorization_view.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/categorization_view.tsx new file mode 100644 index 0000000000000..5017b0c3239e8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/categorization_view.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +import { CategorizationDetectors } from './metric_selection'; +import { CategorizationDetectorsSummary } from './metric_selection_summary'; +import { CategorizationSettings } from './settings'; + +interface Props { + isActive: boolean; + setCanProceed?: (proceed: boolean) => void; +} + +export const CategorizationView: FC = ({ isActive, setCanProceed }) => { + const [categoryFieldValid, setCategoryFieldValid] = useState(false); + const [settingsValid, setSettingsValid] = useState(false); + + useEffect(() => { + if (typeof setCanProceed === 'function') { + setCanProceed(categoryFieldValid && settingsValid); + } + }, [categoryFieldValid, settingsValid]); + + return isActive === false ? ( + + ) : ( + <> + + {categoryFieldValid && ( + <> + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/examples_valid_callout.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/examples_valid_callout.tsx new file mode 100644 index 0000000000000..04934d2dc9a36 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/examples_valid_callout.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiCallOut, EuiSpacer, EuiCallOutProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { CategorizationAnalyzer } from '../../../../../../../services/ml_server_info'; +import { EditCategorizationAnalyzerFlyout } from '../../../common/edit_categorization_analyzer_flyout'; +import { + NUMBER_OF_CATEGORY_EXAMPLES, + CATEGORY_EXAMPLES_MULTIPLIER, + CATEGORY_EXAMPLES_ERROR_LIMIT, + CATEGORY_EXAMPLES_WARNING_LIMIT, +} from '../../../../../../../../../common/constants/new_job'; + +type CategorizationAnalyzerType = CategorizationAnalyzer | null; + +interface Props { + examplesValid: number; + categorizationAnalyzer: CategorizationAnalyzerType; +} + +export const ExamplesValidCallout: FC = ({ examplesValid, categorizationAnalyzer }) => { + const percentageText = ; + const analyzerUsed = ; + + let color: EuiCallOutProps['color'] = 'success'; + let title = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldCalloutTitle.valid', + { + defaultMessage: 'Selected category field is valid', + } + ); + + if (examplesValid < CATEGORY_EXAMPLES_ERROR_LIMIT) { + color = 'danger'; + title = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldCalloutTitle.invalid', + { + defaultMessage: 'Selected category field is invalid', + } + ); + } else if (examplesValid < CATEGORY_EXAMPLES_WARNING_LIMIT) { + color = 'warning'; + title = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldCalloutTitle.possiblyInvalid', + { + defaultMessage: 'Selected category field is possibly invalid', + } + ); + } + + return ( + + {percentageText} + + {analyzerUsed} + + ); +}; + +const PercentageText: FC<{ examplesValid: number }> = ({ examplesValid }) => ( +
+ +
+); + +const AnalyzerUsed: FC<{ categorizationAnalyzer: CategorizationAnalyzerType }> = ({ + categorizationAnalyzer, +}) => { + let analyzer = ''; + if (typeof categorizationAnalyzer === null) { + return null; + } + + if (typeof categorizationAnalyzer === 'string') { + analyzer = categorizationAnalyzer; + } else { + if (categorizationAnalyzer?.tokenizer !== undefined) { + analyzer = categorizationAnalyzer?.tokenizer!; + } else if (categorizationAnalyzer?.analyzer !== undefined) { + analyzer = categorizationAnalyzer?.analyzer!; + } + } + + return ( + <> +
+ +
+
+ +
+ + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/field_examples.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/field_examples.tsx new file mode 100644 index 0000000000000..7f9b2e43b9005 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/field_examples.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable, EuiText } from '@elastic/eui'; +import { CategoryExample } from '../../../../../common/results_loader'; + +interface Props { + fieldExamples: CategoryExample[] | null; +} + +const TOKEN_HIGHLIGHT_COLOR = '#b0ccf7'; + +export const FieldExamples: FC = ({ fieldExamples }) => { + if (fieldExamples === null || fieldExamples.length === 0) { + return null; + } + + const columns = [ + { + field: 'example', + name: i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldExamples.title', + { + defaultMessage: 'Examples', + } + ), + render: (example: any) => ( + + {example} + + ), + }, + ]; + const items = fieldExamples.map((example, i) => { + const txt = []; + let tokenCounter = 0; + let buffer = ''; + let charCount = 0; + while (charCount < example.text.length) { + const token = example.tokens[tokenCounter]; + if (token && charCount === token.start_offset) { + txt.push(buffer); + buffer = ''; + txt.push({token.token}); + charCount += token.end_offset - token.start_offset; + tokenCounter++; + } else { + buffer += example.text[charCount]; + charCount++; + } + } + txt.push(buffer); + return { example: txt }; + }); + return ; +}; + +const Token: FC = ({ children }) => ( + {children} +); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/index.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/index.ts new file mode 100644 index 0000000000000..f61dfd88a37bb --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/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 { CategorizationView } from './categorization_view'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx new file mode 100644 index 0000000000000..fda0066f9cd37 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useEffect, useState } from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +import { JobCreatorContext } from '../../../job_creator_context'; +import { CategorizationJobCreator } from '../../../../../common/job_creator'; +import { CategorizationField } from '../categorization_field'; +import { CategorizationDetector } from '../categorization_detector'; +import { FieldExamples } from './field_examples'; +import { ExamplesValidCallout } from './examples_valid_callout'; +import { CategoryExample } from '../../../../../common/results_loader'; +import { LoadingWrapper } from '../../../charts/loading_wrapper'; + +interface Props { + setIsValid: (na: boolean) => void; +} + +export const CategorizationDetectors: FC = ({ setIsValid }) => { + const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const jobCreator = jc as CategorizationJobCreator; + + const [loadingData, setLoadingData] = useState(false); + const [start, setStart] = useState(jobCreator.start); + const [end, setEnd] = useState(jobCreator.end); + const [categorizationAnalyzerString, setCategorizationAnalyzerString] = useState( + JSON.stringify(jobCreator.categorizationAnalyzer) + ); + const [fieldExamples, setFieldExamples] = useState(null); + const [examplesValid, setExamplesValid] = useState(0); + + const [categorizationFieldName, setCategorizationFieldName] = useState( + jobCreator.categorizationFieldName + ); + + useEffect(() => { + if (jobCreator.categorizationFieldName !== categorizationFieldName) { + jobCreator.categorizationFieldName = categorizationFieldName; + jobCreatorUpdate(); + } + loadFieldExamples(); + }, [categorizationFieldName]); + + useEffect(() => { + let updateExamples = false; + if (jobCreator.start !== start || jobCreator.end !== end) { + setStart(jobCreator.start); + setEnd(jobCreator.end); + updateExamples = true; + } + const tempCategorizationAnalyzerString = JSON.stringify(jobCreator.categorizationAnalyzer); + if (tempCategorizationAnalyzerString !== categorizationAnalyzerString) { + setCategorizationAnalyzerString(tempCategorizationAnalyzerString); + updateExamples = true; + } + + if (updateExamples) { + loadFieldExamples(); + } + if (jobCreator.categorizationFieldName !== categorizationFieldName) { + setCategorizationFieldName(jobCreator.categorizationFieldName); + } + }, [jobCreatorUpdated]); + + async function loadFieldExamples() { + if (categorizationFieldName !== null) { + setLoadingData(true); + const { valid, examples } = await jobCreator.loadCategorizationFieldExamples(); + setFieldExamples(examples); + setExamplesValid(valid); + setLoadingData(false); + } else { + setFieldExamples(null); + setExamplesValid(0); + } + setIsValid(categorizationFieldName !== null); + } + + useEffect(() => { + jobCreatorUpdate(); + }, [examplesValid]); + + return ( + <> + + + + {loadingData === true && ( + +
+ + )} + {fieldExamples !== null && loadingData === false && ( + <> + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx new file mode 100644 index 0000000000000..768d8c394fb8f --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useEffect, useState } from 'react'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { CategorizationJobCreator } from '../../../../../common/job_creator'; +import { Results, Anomaly } from '../../../../../common/results_loader'; +import { LineChartPoint } from '../../../../../common/chart_loader'; +import { EventRateChart } from '../../../charts/event_rate_chart'; +import { TopCategories } from './top_categories'; + +const DTR_IDX = 0; + +export const CategorizationDetectorsSummary: FC = () => { + const { jobCreator: jc, chartLoader, resultsLoader, chartInterval } = useContext( + JobCreatorContext + ); + const jobCreator = jc as CategorizationJobCreator; + + const [loadingData, setLoadingData] = useState(false); + const [anomalyData, setAnomalyData] = useState([]); + const [eventRateChartData, setEventRateChartData] = useState([]); + const [jobIsRunning, setJobIsRunning] = useState(false); + + function setResultsWrapper(results: Results) { + const anomalies = results.anomalies[DTR_IDX]; + if (anomalies !== undefined) { + setAnomalyData(anomalies); + } + } + + function watchProgress(progress: number) { + setJobIsRunning(progress > 0); + } + + useEffect(() => { + // subscribe to progress and results + const resultsSubscription = resultsLoader.subscribeToResults(setResultsWrapper); + jobCreator.subscribeToProgress(watchProgress); + loadChart(); + return () => { + resultsSubscription.unsubscribe(); + }; + }, []); + + async function loadChart() { + setLoadingData(true); + try { + const resp = await chartLoader.loadEventRateChart( + jobCreator.start, + jobCreator.end, + chartInterval.getInterval().asMilliseconds() + ); + setEventRateChartData(resp); + } catch (error) { + setEventRateChartData([]); + } + setLoadingData(false); + } + + return ( + <> + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/settings.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/settings.tsx new file mode 100644 index 0000000000000..55db3d495707d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/settings.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, { FC } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { BucketSpan } from '../bucket_span'; + +interface Props { + setIsValid: (proceed: boolean) => void; +} + +export const CategorizationSettings: FC = ({ setIsValid }) => { + return ( + <> + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx new file mode 100644 index 0000000000000..3bade07250b46 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx @@ -0,0 +1,89 @@ +/* + * 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, useContext, useEffect, useState } from 'react'; +import { EuiBasicTable, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { CategorizationJobCreator } from '../../../../../common/job_creator'; +import { Results } from '../../../../../common/results_loader'; +import { ml } from '../../../../../../../services/ml_api_service'; +import { NUMBER_OF_CATEGORY_EXAMPLES } from '../../../../../../../../../common/constants/new_job'; + +export const TopCategories: FC = () => { + const { jobCreator: jc, resultsLoader } = useContext(JobCreatorContext); + const jobCreator = jc as CategorizationJobCreator; + + const [tableRow, setTableRow] = useState>([]); + const [totalCategories, setTotalCategories] = useState(0); + + function setResultsWrapper(results: Results) { + loadTopCats(); + } + + async function loadTopCats() { + const results = await ml.jobs.topCategories(jobCreator.jobId, NUMBER_OF_CATEGORY_EXAMPLES); + setTableRow( + results.categories.map(c => ({ + count: c.count, + example: c.category.examples?.length ? c.category.examples[0] : '', + })) + ); + setTotalCategories(results.total); + } + + useEffect(() => { + // subscribe to result updates + const resultsSubscription = resultsLoader.subscribeToResults(setResultsWrapper); + return () => { + resultsSubscription.unsubscribe(); + }; + }, []); + + const columns = [ + // only include counts if model plot is enabled + ...(jobCreator.modelPlot + ? [ + { + field: 'count', + name: 'count', + width: '100px', + render: (count: any) => ( + + {count} + + ), + }, + ] + : []), + { + field: 'example', + name: 'Example', + render: (example: any) => ( + + {example} + + ), + }, + ]; + + return ( + <> + {totalCategories > 0 && ( + <> +
+ +
+ + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx index 5e800de755f26..b28a9d3da81dc 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, FC } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { JobCreatorContext } from '../../../job_creator_context'; import { BucketSpan } from '../bucket_span'; import { SplitFieldSelector } from '../split_field'; import { Influencers } from '../influencers'; @@ -18,19 +17,6 @@ interface Props { } export const MultiMetricSettings: FC = ({ setIsValid }) => { - const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); - const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); - - useEffect(() => { - jobCreator.bucketSpan = bucketSpan; - jobCreatorUpdate(); - setIsValid(bucketSpan !== ''); - }, [bucketSpan]); - - useEffect(() => { - setBucketSpan(jobCreator.bucketSpan); - }, [jobCreatorUpdated]); - return ( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/settings.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/settings.tsx index 46cdf44ce0f7d..b9de755e6c946 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/settings.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/settings.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, FC } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { JobCreatorContext } from '../../../job_creator_context'; import { BucketSpan } from '../bucket_span'; import { Influencers } from '../influencers'; @@ -16,19 +15,6 @@ interface Props { } export const PopulationSettings: FC = ({ setIsValid }) => { - const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); - const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); - - useEffect(() => { - jobCreator.bucketSpan = bucketSpan; - jobCreatorUpdate(); - setIsValid(bucketSpan !== ''); - }, [bucketSpan]); - - useEffect(() => { - setBucketSpan(jobCreator.bucketSpan); - }, [jobCreatorUpdated]); - return ( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx index c750235051a6f..f8e7275cf15bb 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, FC, useContext } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; @@ -19,18 +19,7 @@ interface Props { } export const SingleMetricSettings: FC = ({ setIsValid }) => { - const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); - const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); - - useEffect(() => { - jobCreator.bucketSpan = bucketSpan; - jobCreatorUpdate(); - setIsValid(bucketSpan !== ''); - }, [bucketSpan]); - - useEffect(() => { - setBucketSpan(jobCreator.bucketSpan); - }, [jobCreatorUpdated]); + const { jobCreator } = useContext(JobCreatorContext); const convertToMultiMetric = () => { convertToMultiMetricJob(jobCreator); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/pick_fields.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/pick_fields.tsx index 795dfc30f954a..bfec49678bc34 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/pick_fields.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/pick_fields.tsx @@ -15,6 +15,7 @@ import { SingleMetricView } from './components/single_metric_view'; import { MultiMetricView } from './components/multi_metric_view'; import { PopulationView } from './components/population_view'; import { AdvancedView } from './components/advanced_view'; +import { CategorizationView } from './components/categorization_view'; import { JsonEditorFlyout, EDITOR_MODE } from '../common/json_editor_flyout'; import { DatafeedPreviewFlyout } from '../common/datafeed_preview_flyout'; @@ -30,7 +31,9 @@ export const PickFieldsStep: FC = ({ setCurrentStep, isCurrentStep }) (jobCreator.type === JOB_TYPE.ADVANCED && jobValidator.modelMemoryLimit.valid)) && jobValidator.bucketSpan.valid && jobValidator.duplicateDetectors.valid && - jobValidator.validating === false; + jobValidator.validating === false && + (jobCreator.type !== JOB_TYPE.CATEGORIZATION || + (jobCreator.type === JOB_TYPE.CATEGORIZATION && jobValidator.categorizationField)); setNextActive(active); }, [jobValidatorUpdated]); @@ -50,6 +53,9 @@ export const PickFieldsStep: FC = ({ setCurrentStep, isCurrentStep }) {jobType === JOB_TYPE.ADVANCED && ( )} + {jobType === JOB_TYPE.CATEGORIZATION && ( + + )} setCurrentStep( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/detector_chart/detector_chart.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/detector_chart/detector_chart.tsx index f72ff6cf985e5..564228604d71e 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/detector_chart/detector_chart.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/detector_chart/detector_chart.tsx @@ -11,6 +11,7 @@ import { SingleMetricView } from '../../../pick_fields_step/components/single_me import { MultiMetricView } from '../../../pick_fields_step/components/multi_metric_view'; import { PopulationView } from '../../../pick_fields_step/components/population_view'; import { AdvancedView } from '../../../pick_fields_step/components/advanced_view'; +import { CategorizationView } from '../../../pick_fields_step/components/categorization_view'; export const DetectorChart: FC = () => { const { jobCreator } = useContext(JobCreatorContext); @@ -21,6 +22,7 @@ export const DetectorChart: FC = () => { {jobCreator.type === JOB_TYPE.MULTI_METRIC && } {jobCreator.type === JOB_TYPE.POPULATION && } {jobCreator.type === JOB_TYPE.ADVANCED && } + {jobCreator.type === JOB_TYPE.CATEGORIZATION && } ); }; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts index 8500279e742b7..51fc226751ae2 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts @@ -32,15 +32,24 @@ function getWizardUrlFromCloningJob(job: CombinedJob) { const created = job?.custom_settings?.created_by; let page = ''; - if (created === CREATED_BY_LABEL.SINGLE_METRIC) { - page = JOB_TYPE.SINGLE_METRIC; - } else if (created === CREATED_BY_LABEL.MULTI_METRIC) { - page = JOB_TYPE.MULTI_METRIC; - } else if (created === CREATED_BY_LABEL.POPULATION) { - page = JOB_TYPE.POPULATION; - } else { - page = JOB_TYPE.ADVANCED; + switch (created) { + case CREATED_BY_LABEL.SINGLE_METRIC: + page = JOB_TYPE.SINGLE_METRIC; + break; + case CREATED_BY_LABEL.MULTI_METRIC: + page = JOB_TYPE.MULTI_METRIC; + break; + case CREATED_BY_LABEL.POPULATION: + page = JOB_TYPE.POPULATION; + break; + case CREATED_BY_LABEL.CATEGORIZATION: + page = JOB_TYPE.CATEGORIZATION; + break; + default: + page = JOB_TYPE.ADVANCED; + break; } + const indexPatternId = getIndexPatternIdFromName(job.datafeed_config.indices[0]); return `jobs/new_job/${page}?index=${indexPatternId}&_g=()`; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx index dbae1948cbe0f..9a44d561c2d94 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx @@ -151,6 +151,22 @@ export const Page: FC = () => { }), id: 'mlJobTypeLinkAdvancedJob', }, + { + href: getUrl('#jobs/new_job/categorization'), + icon: { + type: 'createAdvancedJob', + ariaLabel: i18n.translate('xpack.ml.newJob.wizard.jobType.categorizationAriaLabel', { + defaultMessage: 'Categorization job', + }), + }, + title: i18n.translate('xpack.ml.newJob.wizard.jobType.categorizationTitle', { + defaultMessage: 'Categorization', + }), + description: i18n.translate('xpack.ml.newJob.wizard.jobType.categorizationDescription', { + defaultMessage: 'Group log messages into categories and detect anomalies within them.', + }), + id: 'mlJobTypeLinkCategorizationJob', + }, ]; return ( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx index 2e025c4931818..3a37934e6203a 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx @@ -19,8 +19,12 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { Wizard } from './wizard'; import { WIZARD_STEPS } from '../components/step_types'; -import { jobCreatorFactory, isAdvancedJobCreator } from '../../common/job_creator'; import { getJobCreatorTitle } from '../../common/job_creator/util/general'; +import { + jobCreatorFactory, + isAdvancedJobCreator, + isCategorizationJobCreator, +} from '../../common/job_creator'; import { JOB_TYPE, DEFAULT_MODEL_MEMORY_LIMIT, @@ -34,6 +38,9 @@ import { getTimeFilterRange } from '../../../../components/full_time_range_selec import { TimeBuckets } from '../../../../util/time_buckets'; import { ExistingJobsAndGroups, mlJobService } from '../../../../services/job_service'; import { expandCombinedJobConfig } from '../../common/job_creator/configs'; +import { newJobCapsService } from '../../../../services/new_job_capabilities_service'; +import { EVENT_RATE_FIELD_ID } from '../../../../../../common/types/fields'; +import { getNewJobDefaults } from '../../../../services/ml_server_info'; const PAGE_WIDTH = 1200; // document.querySelector('.single-metric-job-container').width(); const BAR_TARGET = PAGE_WIDTH > 2000 ? 1000 : PAGE_WIDTH / 2; @@ -66,6 +73,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { // cloning a job const clonedJob = mlJobService.cloneJob(mlJobService.tempJobCloningObjects.job); const { job, datafeed } = expandCombinedJobConfig(clonedJob); + initCategorizationSettings(); jobCreator.cloneFromExistingJob(job, datafeed); // if we're not skipping the time range, this is a standard job clone, so wipe the jobId @@ -103,7 +111,11 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { // creating a new job jobCreator.bucketSpan = DEFAULT_BUCKET_SPAN; - if (jobCreator.type !== JOB_TYPE.POPULATION && jobCreator.type !== JOB_TYPE.ADVANCED) { + if ( + jobCreator.type !== JOB_TYPE.POPULATION && + jobCreator.type !== JOB_TYPE.ADVANCED && + jobCreator.type !== JOB_TYPE.CATEGORIZATION + ) { // for all other than population or advanced, use 10MB jobCreator.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; } @@ -120,6 +132,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { // auto set the time range if creating a new advanced job autoSetTimeRange = isAdvancedJobCreator(jobCreator); + initCategorizationSettings(); } if (autoSetTimeRange && isAdvancedJobCreator(jobCreator)) { @@ -137,6 +150,20 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { } } + function initCategorizationSettings() { + if (isCategorizationJobCreator(jobCreator)) { + // categorization job will always use a count agg, so give it + // to the job creator now + const count = newJobCapsService.getAggById('count'); + const rare = newJobCapsService.getAggById('rare'); + const eventRate = newJobCapsService.getFieldById(EVENT_RATE_FIELD_ID); + jobCreator.setDefaultDetectorProperties(count, rare, eventRate); + + const { anomaly_detectors: anomalyDetectors } = getNewJobDefaults(); + jobCreator.categorizationAnalyzer = anomalyDetectors.categorization_analyzer!; + } + } + const chartInterval = new TimeBuckets(); chartInterval.setBarTarget(BAR_TARGET); chartInterval.setMaxBars(MAX_BARS); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts index d1e81c1c344de..0f19451b23263 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts @@ -4,12 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - IndexPattern, - esQuery, - Query, - esKuery, -} from '../../../../../../../../../src/plugins/data/public'; +import { esQuery, Query, esKuery } from '../../../../../../../../../src/plugins/data/public'; +import { IIndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns'; import { KibanaConfigTypeFix } from '../../../contexts/kibana'; import { SEARCH_QUERY_LANGUAGE } from '../../../../../common/constants/search'; import { SavedSearchSavedObject } from '../../../../../common/types/kibana'; @@ -19,7 +15,7 @@ import { getQueryFromSavedSearch } from '../../../util/index_utils'; export function createSearchItems( kibanaConfig: KibanaConfigTypeFix, - indexPattern: IndexPattern, + indexPattern: IIndexPattern, savedSearch: SavedSearchSavedObject | null ) { // query is only used by the data visualizer as it needs diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/new_job/wizard.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/new_job/wizard.tsx index ea1baefdce0d1..99c0511cd09ce 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/new_job/wizard.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/new_job/wizard.tsx @@ -72,6 +72,16 @@ const advancedBreadcrumbs = [ }, ]; +const categorizationBreadcrumbs = [ + ...baseBreadcrumbs, + { + text: i18n.translate('xpack.ml.jobsBreadcrumbs.categorizationLabel', { + defaultMessage: 'Categorization', + }), + href: '', + }, +]; + export const singleMetricRoute: MlRoute = { path: '/jobs/new_job/single_metric', render: (props, config, deps) => ( @@ -104,6 +114,14 @@ export const advancedRoute: MlRoute = { breadcrumbs: advancedBreadcrumbs, }; +export const categorizationRoute: MlRoute = { + path: '/jobs/new_job/categorization', + render: (props, config, deps) => ( + + ), + breadcrumbs: categorizationBreadcrumbs, +}; + const PageWrapper: FC = ({ location, config, jobType, deps }) => { const { index, savedSearchId } = queryString.parse(location.search); const { context, results } = useResolver(index, savedSearchId, config, { diff --git a/x-pack/legacy/plugins/ml/public/application/routing/use_resolver.ts b/x-pack/legacy/plugins/ml/public/application/routing/use_resolver.ts index f74260c06567e..3716b9715bb5b 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/use_resolver.ts +++ b/x-pack/legacy/plugins/ml/public/application/routing/use_resolver.ts @@ -60,8 +60,6 @@ export const useResolver = ( } } catch (error) { // quietly fail. Let the resolvers handle the redirection if any fail to resolve - // eslint-disable-next-line no-console - console.error('ML page loading resolver', error); } })(); }, []); diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts index 58692754d95d7..2ad2a148f05d1 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts @@ -7,6 +7,7 @@ import { Observable } from 'rxjs'; import { Annotation } from '../../../../common/types/annotations'; import { AggFieldNamePair } from '../../../../common/types/fields'; +import { Category } from '../../../../common/types/categories'; import { ExistingJobsAndGroups } from '../job_service'; import { PrivilegesResponse } from '../../../../common/types/privileges'; import { MlSummaryJobs } from '../../../../common/types/jobs'; @@ -107,7 +108,7 @@ declare interface Ml { checkManageMLPrivileges(): Promise; getJobStats(obj: object): Promise; getDatafeedStats(obj: object): Promise; - esSearch(obj: object): any; + esSearch(obj: object): Promise; esSearch$(obj: object): Observable; getIndices(): Promise; dataRecognizerModuleJobsExist(obj: { moduleId: string }): Promise; @@ -171,6 +172,20 @@ declare interface Ml { start: number, end: number ): Promise<{ progress: number; isRunning: boolean; isJobClosed: boolean }>; + categorizationFieldExamples( + indexPatternTitle: string, + query: object, + size: number, + field: string, + timeField: string | undefined, + start: number, + end: number, + analyzer: any + ): Promise<{ valid: number; examples: any[] }>; + topCategories( + jobId: string, + count: number + ): Promise<{ total: number; categories: Array<{ count?: number; category: Category }> }>; }; estimateBucketSpan(data: BucketSpanEstimatorData): Promise; diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js index 4bec070b2cfdf..05d98dc1a1e64 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js @@ -206,4 +206,41 @@ export const jobs = { }, }); }, + + categorizationFieldExamples( + indexPatternTitle, + query, + size, + field, + timeField, + start, + end, + analyzer + ) { + return http({ + url: `${basePath}/jobs/categorization_field_examples`, + method: 'POST', + data: { + indexPatternTitle, + query, + size, + field, + timeField, + start, + end, + analyzer, + }, + }); + }, + + topCategories(jobId, count) { + return http({ + url: `${basePath}/jobs/top_categories`, + method: 'POST', + data: { + jobId, + count, + }, + }); + }, }; diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_server_info.ts b/x-pack/legacy/plugins/ml/public/application/services/ml_server_info.ts index 95d670eda8a4f..6bf5a7b0c9743 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_server_info.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_server_info.ts @@ -11,10 +11,18 @@ export interface MlServerDefaults { categorization_examples_limit?: number; model_memory_limit?: string; model_snapshot_retention_days?: number; + categorization_analyzer?: CategorizationAnalyzer; }; datafeeds: { scroll_size?: number }; } +export interface CategorizationAnalyzer { + char_filter?: any[]; + tokenizer?: string; + filter?: any[]; + analyzer?: string; +} + export interface MlServerLimits { max_model_memory_limit?: string; } diff --git a/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts b/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts index d78c9298c6073..051973d35d8de 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts @@ -15,7 +15,6 @@ import { import { ES_FIELD_TYPES, IIndexPattern, - IndexPattern, IndexPatternsContract, } from '../../../../../../../src/plugins/data/public'; import { ml } from './ml_api_service'; @@ -31,7 +30,7 @@ export function loadNewJobCapabilities( return new Promise(async (resolve, reject) => { if (indexPatternId !== undefined) { // index pattern is being used - const indexPattern: IndexPattern = await indexPatterns.get(indexPatternId); + const indexPattern: IIndexPattern = await indexPatterns.get(indexPatternId); await newJobCapsService.initializeFromIndexPattern(indexPattern); resolve(newJobCapsService.newJobCaps); } else if (savedSearchId !== undefined) { diff --git a/x-pack/legacy/plugins/ml/public/application/util/index_utils.ts b/x-pack/legacy/plugins/ml/public/application/util/index_utils.ts index 2b8838c04cf69..2e176b0044314 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/index_utils.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/index_utils.ts @@ -8,7 +8,11 @@ import { toastNotifications } from 'ui/notify'; import { i18n } from '@kbn/i18n'; import chrome from 'ui/chrome'; import { Query } from 'src/plugins/data/public'; -import { IndexPattern, IndexPatternsContract } from '../../../../../../../src/plugins/data/public'; +import { + IndexPattern, + IIndexPattern, + IndexPatternsContract, +} from '../../../../../../../src/plugins/data/public'; import { IndexPatternSavedObject, SavedSearchSavedObject } from '../../../common/types/kibana'; let indexPatternCache: IndexPatternSavedObject[] = []; @@ -71,7 +75,7 @@ export function getIndexPatternIdFromName(name: string) { } export async function getIndexPatternAndSavedSearch(savedSearchId: string) { - const resp: { savedSearch: SavedSearchSavedObject | null; indexPattern: IndexPattern | null } = { + const resp: { savedSearch: SavedSearchSavedObject | null; indexPattern: IIndexPattern | null } = { savedSearch: null, indexPattern: null, }; diff --git a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js index 2ca21efb0bd6a..cf13d329182ba 100644 --- a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js +++ b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js @@ -753,4 +753,29 @@ export const elasticsearchJsPlugin = (Client, config, components) => { ], method: 'GET', }); + + ml.categories = ca({ + urls: [ + { + fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/results/categories/<%=categoryId%>', + req: { + jobId: { + type: 'string', + }, + categoryId: { + type: 'string', + }, + }, + }, + { + fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/results/categories', + req: { + jobId: { + type: 'string', + }, + }, + }, + ], + method: 'GET', + }); }; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/index.js b/x-pack/legacy/plugins/ml/server/models/job_service/index.js index 78d099dad6606..186bcbae84546 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/index.js +++ b/x-pack/legacy/plugins/ml/server/models/job_service/index.js @@ -8,7 +8,7 @@ import { datafeedsProvider } from './datafeeds'; import { jobsProvider } from './jobs'; import { groupsProvider } from './groups'; import { newJobCapsProvider } from './new_job_caps'; -import { newJobChartsProvider } from './new_job'; +import { newJobChartsProvider, categorizationExamplesProvider } from './new_job'; export function jobServiceProvider(callWithRequest, request) { return { @@ -17,5 +17,6 @@ export function jobServiceProvider(callWithRequest, request) { ...groupsProvider(callWithRequest), ...newJobCapsProvider(callWithRequest, request), ...newJobChartsProvider(callWithRequest, request), + ...categorizationExamplesProvider(callWithRequest, request), }; } diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/categorization.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/categorization.ts new file mode 100644 index 0000000000000..34e871a936088 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/categorization.ts @@ -0,0 +1,294 @@ +/* + * 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 { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns'; +import { CATEGORY_EXAMPLES_MULTIPLIER } from '../../../../common/constants/new_job'; +import { CategoryId, Category, Token } from '../../../../common/types/categories'; +import { callWithRequestType } from '../../../../common/types/kibana'; + +export function categorizationExamplesProvider(callWithRequest: callWithRequestType) { + async function categorizationExamples( + indexPatternTitle: string, + query: any, + size: number, + categorizationFieldName: string, + timeField: string | undefined, + start: number, + end: number, + analyzer?: any + ) { + if (timeField !== undefined) { + const range = { + range: { + [timeField]: { + gte: start, + format: 'epoch_millis', + }, + }, + }; + + if (query.bool === undefined) { + query.bool = {}; + } + if (query.bool.filter === undefined) { + query.bool.filter = range; + } else { + if (Array.isArray(query.bool.filter)) { + query.bool.filter.push(range); + } else { + query.bool.filter.range = range; + } + } + } + + const results = await callWithRequest('search', { + index: indexPatternTitle, + size, + body: { + _source: categorizationFieldName, + query, + }, + }); + const examples: string[] = results.hits?.hits + ?.map((doc: any) => doc._source[categorizationFieldName]) + .filter((example: string | undefined) => example !== undefined); + + let tokens: Token[] = []; + try { + const { tokens: tempTokens } = await callWithRequest('indices.analyze', { + body: { + ...getAnalyzer(analyzer), + text: examples, + }, + }); + tokens = tempTokens; + } catch (error) { + // fail silently, the tokens could not be loaded + // an empty list of tokens will be returned for each example + } + + const lengths = examples.map(e => e.length); + const sumLengths = lengths.map((s => (a: number) => (s += a))(0)); + + const tokensPerExample: Token[][] = examples.map(e => []); + + tokens.forEach((t, i) => { + for (let g = 0; g < sumLengths.length; g++) { + if (t.start_offset <= sumLengths[g] + g) { + const offset = g > 0 ? sumLengths[g - 1] + g : 0; + tokensPerExample[g].push({ + ...t, + start_offset: t.start_offset - offset, + end_offset: t.end_offset - offset, + }); + break; + } + } + }); + + return examples.map((e, i) => ({ text: e, tokens: tokensPerExample[i] })); + } + + function getAnalyzer(analyzer: any) { + if (typeof analyzer === 'object' && analyzer.tokenizer !== undefined) { + return analyzer; + } else { + return { analyzer: 'standard' }; + } + } + + async function validateCategoryExamples( + indexPatternTitle: string, + query: any, + size: number, + categorizationFieldName: string, + timeField: string | undefined, + start: number, + end: number, + analyzer?: any + ) { + const examples = await categorizationExamples( + indexPatternTitle, + query, + size * CATEGORY_EXAMPLES_MULTIPLIER, + categorizationFieldName, + timeField, + start, + end, + analyzer + ); + + const sortedExamples = examples + .map((e, i) => ({ ...e, origIndex: i })) + .sort((a, b) => b.tokens.length - a.tokens.length); + const validExamples = sortedExamples.filter(e => e.tokens.length > 1); + + return { + valid: sortedExamples.length === 0 ? 0 : validExamples.length / sortedExamples.length, + examples: sortedExamples + .filter( + (e, i) => + i / CATEGORY_EXAMPLES_MULTIPLIER - Math.floor(i / CATEGORY_EXAMPLES_MULTIPLIER) === 0 + ) + .sort((a, b) => a.origIndex - b.origIndex) + .map(e => ({ text: e.text, tokens: e.tokens })), + }; + } + + async function getTotalCategories(jobId: string): Promise<{ total: number }> { + const totalResp = await callWithRequest('search', { + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + term: { + job_id: jobId, + }, + }, + { + exists: { + field: 'category_id', + }, + }, + ], + }, + }, + }, + }); + return totalResp?.hits?.total?.value ?? 0; + } + + async function getTopCategoryCounts(jobId: string, numberOfCategories: number) { + const top = await callWithRequest('search', { + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + term: { + job_id: jobId, + }, + }, + { + term: { + result_type: 'model_plot', + }, + }, + { + term: { + by_field_name: 'mlcategory', + }, + }, + ], + }, + }, + aggs: { + cat_count: { + terms: { + field: 'by_field_value', + size: numberOfCategories, + }, + }, + }, + }, + }); + + const catCounts: Array<{ + id: CategoryId; + count: number; + }> = top.aggregations?.cat_count?.buckets.map((c: any) => ({ + id: c.key, + count: c.doc_count, + })); + return catCounts || []; + } + + async function getCategories( + jobId: string, + catIds: CategoryId[], + size: number + ): Promise { + const categoryFilter = catIds.length + ? { + terms: { + category_id: catIds, + }, + } + : { + exists: { + field: 'category_id', + }, + }; + const result = await callWithRequest('search', { + index: ML_RESULTS_INDEX_PATTERN, + size, + body: { + query: { + bool: { + filter: [ + { + term: { + job_id: jobId, + }, + }, + categoryFilter, + ], + }, + }, + }, + }); + + return result.hits.hits?.map((c: { _source: Category }) => c._source) || []; + } + + async function topCategories(jobId: string, numberOfCategories: number) { + const catCounts = await getTopCategoryCounts(jobId, numberOfCategories); + const categories = await getCategories( + jobId, + catCounts.map(c => c.id), + catCounts.length || numberOfCategories + ); + + const catsById = categories.reduce((p, c) => { + p[c.category_id] = c; + return p; + }, {} as { [id: number]: Category }); + + const total = await getTotalCategories(jobId); + + if (catCounts.length) { + return { + total, + categories: catCounts.map(({ id, count }) => { + return { + count, + category: catsById[id] ?? null, + }; + }), + }; + } else { + return { + total, + categories: categories.map(category => { + return { + category, + }; + }), + }; + } + } + + return { + categorizationExamples, + validateCategoryExamples, + topCategories, + }; +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts index a8b6ba494a070..88ae8caa91e4a 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts @@ -6,7 +6,7 @@ import { newJobLineChartProvider } from './line_chart'; import { newJobPopulationChartProvider } from './population_chart'; -export type callWithRequestType = (action: string, params: any) => Promise; +import { callWithRequestType } from '../../../../common/types/kibana'; export function newJobChartsProvider(callWithRequest: callWithRequestType) { const { newJobLineChart } = newJobLineChartProvider(callWithRequest); diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts index 758b834ed7b3a..da23efa67d0b5 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts @@ -5,3 +5,4 @@ */ export { newJobChartsProvider } from './charts'; +export { categorizationExamplesProvider } from './categorization'; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts index 5bb0f39982146..c1a5ad5e38ecc 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts @@ -6,10 +6,9 @@ import { get } from 'lodash'; import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { callWithRequestType } from '../../../../common/types/kibana'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; -export type callWithRequestType = (action: string, params: any) => Promise; - type DtrIndex = number; type TimeStamp = number; type Value = number | undefined | null; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts index 812a135f6cf08..ee35f13c44ee6 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts @@ -6,10 +6,9 @@ import { get } from 'lodash'; import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { callWithRequestType } from '../../../../common/types/kibana'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; -export type callWithRequestType = (action: string, params: any) => Promise; - const OVER_FIELD_EXAMPLES_COUNT = 40; type DtrIndex = number; diff --git a/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.js b/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.js index 4205027eb5ce2..54d447c288151 100644 --- a/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.js +++ b/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.js @@ -181,4 +181,22 @@ export function jobRoutes({ commonRouteConfig, elasticsearchPlugin, route }) { ...commonRouteConfig, }, }); + + route({ + method: 'GET', + path: '/api/ml/anomaly_detectors/{jobId}/results/categories/{categoryId}', + handler(request, reply) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const options = { + jobId: request.params.jobId, + categoryId: request.params.categoryId, + }; + return callWithRequest('ml.categories', options) + .then(resp => reply(resp)) + .catch(resp => reply(wrapError(resp))); + }, + config: { + ...commonRouteConfig, + }, + }); } diff --git a/x-pack/legacy/plugins/ml/server/routes/job_service.js b/x-pack/legacy/plugins/ml/server/routes/job_service.js index 78c57670b7ab3..a83b4fa403f65 100644 --- a/x-pack/legacy/plugins/ml/server/routes/job_service.js +++ b/x-pack/legacy/plugins/ml/server/routes/job_service.js @@ -270,4 +270,50 @@ export function jobServiceRoutes({ commonRouteConfig, elasticsearchPlugin, route ...commonRouteConfig, }, }); + + route({ + method: 'POST', + path: '/api/ml/jobs/categorization_field_examples', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { validateCategoryExamples } = jobServiceProvider(callWithRequest); + const { + indexPatternTitle, + timeField, + query, + size, + field, + start, + end, + analyzer, + } = request.payload; + return validateCategoryExamples( + indexPatternTitle, + query, + size, + field, + timeField, + start, + end, + analyzer + ).catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig, + }, + }); + + route({ + method: 'POST', + path: '/api/ml/jobs/top_categories', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { topCategories } = jobServiceProvider(callWithRequest); + const { jobId, count } = request.payload; + return topCategories(jobId, count).catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig, + }, + }); } From 459cad534c37984cf24191672b88420271a62905 Mon Sep 17 00:00:00 2001 From: cachedout Date: Thu, 9 Jan 2020 15:30:58 +0000 Subject: [PATCH 41/63] [Monitoring] h1 elements for accessibility (#52276) * Add header element to indices page for WCAG * Add h1 element for WCAG to node page * Add h1 element for WCAG to stack monitoring overview page * Add h1 to advanced nodes page in stack monitoring * Add h1 to nodes page in stack monitoring * Add h1 header for index advanced page in stack monitoring * Standarize more on ide for h1 tag * Give heading element to beats overview * Update Beats listing page for H1 compat with WAVE * Modified beat page to comply with heading rules from WCAG * Kibana instance listing page updated for header WCAG * Add WCAG header fix to logstash listing page * Added headings for WCAG to logstash overview page * Update pipeline listing page for WCAG A headings * Fix WCAG heading problems in pipeline viewer * Fix screen reader heading for APM overview page * Update APM instances page for screen reader headings * Update APM instance page for screen reader heading * Update ccr page for screen reader headings * More a11y fixes for headings in stack monitoring * Fixup * Consistant captalization per review * Removed help text per review comment * Include Elasticsearch node into screen reader message, per review feedback * Update snapshots * Linting * Implement review suggestion for i8n compat * Revert back to just plain string Co-authored-by: Elastic Machine --- .../components/apm/instance/instance.js | 10 +++++++ .../components/apm/instances/instances.js | 18 ++++++++++- .../public/components/apm/overview/index.js | 10 +++++++ .../public/components/beats/beat/beat.js | 7 +++++ .../components/beats/listing/listing.js | 18 ++++++++++- .../__snapshots__/overview.test.js.snap | 30 +++++++++++++++---- .../components/beats/overview/overview.js | 21 +++++++++---- .../cluster/overview/alerts_panel.js | 4 +-- .../components/cluster/overview/index.js | 11 ++++++- .../ccr/__snapshots__/ccr.test.js.snap | 9 ++++++ .../components/elasticsearch/ccr/ccr.js | 9 ++++++ .../__snapshots__/ccr_shard.test.js.snap | 4 +-- .../elasticsearch/ccr_shard/ccr_shard.js | 2 +- .../elasticsearch/index/advanced.js | 10 +++++++ .../elasticsearch/indices/indices.js | 9 ++++++ .../components/elasticsearch/node/advanced.js | 10 +++++++ .../components/elasticsearch/node/node.js | 11 ++++++- .../components/elasticsearch/nodes/nodes.js | 10 +++++++ .../components/kibana/instances/instances.js | 9 ++++++ .../public/components/license/index.js | 6 ++++ .../components/logstash/listing/listing.js | 18 ++++++++++- .../components/logstash/overview/overview.js | 10 +++++++ .../pipeline_listing/pipeline_listing.js | 10 +++++++ .../pipeline_viewer.test.js.snap | 18 +++++++++++ .../statement_list_heading.test.js.snap | 4 +-- .../pipeline_viewer/views/pipeline_viewer.js | 11 ++++++- .../views/statement_list_heading.js | 2 +- .../__snapshots__/no_data.test.js.snap | 10 +++++++ .../public/components/no_data/no_data.js | 17 +++++++++++ 29 files changed, 292 insertions(+), 26 deletions(-) diff --git a/x-pack/legacy/plugins/monitoring/public/components/apm/instance/instance.js b/x-pack/legacy/plugins/monitoring/public/components/apm/instance/instance.js index 47e8bc3b46474..cb7187a8c0753 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/apm/instance/instance.js +++ b/x-pack/legacy/plugins/monitoring/public/components/apm/instance/instance.js @@ -14,8 +14,10 @@ import { EuiPageBody, EuiFlexGroup, EuiPageContent, + EuiScreenReaderOnly, } from '@elastic/eui'; import { Status } from './status'; +import { FormattedMessage } from '@kbn/i18n/react'; export function ApmServerInstance({ summary, metrics, ...props }) { const seriesToShow = [ @@ -45,6 +47,14 @@ export function ApmServerInstance({ summary, metrics, ...props }) { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js b/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js index cd9ee6c9433d0..f3a888bf9e905 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js +++ b/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js @@ -8,7 +8,14 @@ import React, { Fragment } from 'react'; import moment from 'moment'; import { uniq, get } from 'lodash'; import { EuiMonitoringTable } from '../../table'; -import { EuiLink, EuiPage, EuiPageBody, EuiPageContent, EuiSpacer } from '@elastic/eui'; +import { + EuiLink, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiScreenReaderOnly, +} from '@elastic/eui'; import { Status } from './status'; import { formatMetric } from '../../../lib/format_number'; import { formatTimestampToDuration } from '../../../../common'; @@ -16,6 +23,7 @@ import { i18n } from '@kbn/i18n'; import { APM_SYSTEM_ID } from '../../../../common/constants'; import { ListingCallOut } from '../../setup_mode/listing_callout'; import { SetupModeBadge } from '../../setup_mode/badge'; +import { FormattedMessage } from '@kbn/i18n/react'; function getColumns(setupMode) { return [ @@ -133,6 +141,14 @@ export function ApmServerInstances({ apms, setupMode }) { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/apm/overview/index.js b/x-pack/legacy/plugins/monitoring/public/components/apm/overview/index.js index 8ef996d4d725d..c053edc805611 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/apm/overview/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/apm/overview/index.js @@ -14,8 +14,10 @@ import { EuiPageBody, EuiPanel, EuiPageContent, + EuiScreenReaderOnly, } from '@elastic/eui'; import { Status } from '../instances/status'; +import { FormattedMessage } from '@kbn/i18n/react'; export function ApmOverview({ stats, metrics, ...props }) { const seriesToShow = [ @@ -45,6 +47,14 @@ export function ApmOverview({ stats, metrics, ...props }) { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/beats/beat/beat.js b/x-pack/legacy/plugins/monitoring/public/components/beats/beat/beat.js index f3391965be145..3fe211c0f2edc 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/beats/beat/beat.js +++ b/x-pack/legacy/plugins/monitoring/public/components/beats/beat/beat.js @@ -15,9 +15,11 @@ import { EuiSpacer, EuiPageContent, EuiPanel, + EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SummaryStatus } from '../../summary_status'; +import { FormattedMessage } from '@kbn/i18n/react'; export function Beat({ summary, metrics, ...props }) { const metricsToShow = [ @@ -137,6 +139,11 @@ export function Beat({ summary, metrics, ...props }) { + +

+ +

+
{metricsToShow.map((metric, index) => ( diff --git a/x-pack/legacy/plugins/monitoring/public/components/beats/listing/listing.js b/x-pack/legacy/plugins/monitoring/public/components/beats/listing/listing.js index 66af12af3db8f..dfc9117ef48bc 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/beats/listing/listing.js +++ b/x-pack/legacy/plugins/monitoring/public/components/beats/listing/listing.js @@ -6,7 +6,14 @@ import React, { PureComponent } from 'react'; import { uniq, get } from 'lodash'; -import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiLink } from '@elastic/eui'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiLink, + EuiScreenReaderOnly, +} from '@elastic/eui'; import { Stats } from 'plugins/monitoring/components/beats'; import { formatMetric } from 'plugins/monitoring/lib/format_number'; import { EuiMonitoringTable } from 'plugins/monitoring/components/table'; @@ -14,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import { BEATS_SYSTEM_ID } from '../../../../common/constants'; import { ListingCallOut } from '../../setup_mode/listing_callout'; import { SetupModeBadge } from '../../setup_mode/badge'; +import { FormattedMessage } from '@kbn/i18n/react'; export class Listing extends PureComponent { getColumns() { @@ -139,6 +147,14 @@ export class Listing extends PureComponent { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/beats/overview/__snapshots__/overview.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/beats/overview/__snapshots__/overview.test.js.snap index 8065cb8a3fa30..22d012a2ccca7 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/beats/overview/__snapshots__/overview.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/beats/overview/__snapshots__/overview.test.js.snap @@ -3,6 +3,15 @@ exports[`Overview that overview page renders normally 1`] = ` + +

+ +

+
-

+

-

+ -

+

-

+ -

+

-

+ + +

+ +

+
-

+

-

+
@@ -45,12 +46,12 @@ function renderLatestActive(latestActive, latestTypes, latestVersions) { -

+

-

+
@@ -59,12 +60,12 @@ function renderLatestActive(latestActive, latestTypes, latestVersions) { -

+

-

+
@@ -109,6 +110,14 @@ export function BeatsOverview({ return ( + +

+ +

+
{renderLatestActive(latestActive, latestTypes, latestVersions)} diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js index e65aec8602f40..33b26c7ec56e0 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js @@ -91,12 +91,12 @@ export function AlertsPanel({ alerts, changeUrl }) { -

+

-

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js index 3014a74160107..cad4bbf411c34 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js @@ -11,9 +11,10 @@ import { LogstashPanel } from './logstash_panel'; import { AlertsPanel } from './alerts_panel'; import { BeatsPanel } from './beats_panel'; -import { EuiPage, EuiPageBody } from '@elastic/eui'; +import { EuiPage, EuiPageBody, EuiScreenReaderOnly } from '@elastic/eui'; import { ApmPanel } from './apm_panel'; import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../common/constants'; +import { FormattedMessage } from '@kbn/i18n/react'; export function Overview(props) { const isFromStandaloneCluster = props.cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID; @@ -21,6 +22,14 @@ export function Overview(props) { return ( + +

+ +

+
{!isFromStandaloneCluster ? ( diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr/__snapshots__/ccr.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr/__snapshots__/ccr.test.js.snap index bb765d0ab7b4e..37e0039c94ec4 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr/__snapshots__/ccr.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr/__snapshots__/ccr.test.js.snap @@ -3,6 +3,15 @@ exports[`Ccr that it renders normally 1`] = ` + +

+ +

+
+ +

+ +

+
{this.renderTable()} diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap index 3d03d13dde1e1..02e42b8647c03 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap @@ -145,9 +145,9 @@ exports[`CcrShard that it renders normally 1`] = ` -

+

September 27, 2018 9:32:09 AM -

+
-

{formatDateTimeLocal(timestamp)}

+

{formatDateTimeLocal(timestamp)}

{JSON.stringify(stat, null, 2)} diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/index/advanced.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/index/advanced.js index 9e4c70307bec7..a0a339fabef92 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/index/advanced.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/index/advanced.js @@ -13,9 +13,11 @@ import { EuiSpacer, EuiFlexGrid, EuiFlexItem, + EuiScreenReaderOnly, } from '@elastic/eui'; import { IndexDetailStatus } from '../index_detail_status'; import { MonitoringTimeseriesContainer } from '../../chart'; +import { FormattedMessage } from '@kbn/i18n/react'; export const AdvancedIndex = ({ indexSummary, metrics, ...props }) => { const metricsToShow = [ @@ -35,6 +37,14 @@ export const AdvancedIndex = ({ indexSummary, metrics, ...props }) => { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/indices/indices.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/indices/indices.js index 232815e930388..f8dd7b0af7a17 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/indices/indices.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/indices/indices.js @@ -19,6 +19,7 @@ import { EuiPanel, EuiSwitch, EuiSpacer, + EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -130,6 +131,14 @@ export const ElasticsearchIndices = ({ return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/advanced.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/advanced.js index cc4d25c2e2eda..6fea34ed9c901 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/advanced.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/advanced.js @@ -13,9 +13,11 @@ import { EuiSpacer, EuiFlexGrid, EuiFlexItem, + EuiScreenReaderOnly, } from '@elastic/eui'; import { NodeDetailStatus } from '../node_detail_status'; import { MonitoringTimeseriesContainer } from '../../chart'; +import { FormattedMessage } from '@kbn/i18n/react'; export const AdvancedNode = ({ nodeSummary, metrics, ...props }) => { const metricsToShow = [ @@ -39,6 +41,14 @@ export const AdvancedNode = ({ nodeSummary, metrics, ...props }) => { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/node.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/node.js index b585de23c6254..e8b3ef7e680f8 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/node.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/node.js @@ -13,11 +13,13 @@ import { EuiFlexGrid, EuiFlexItem, EuiPanel, + EuiScreenReaderOnly, } from '@elastic/eui'; import { NodeDetailStatus } from '../node_detail_status'; import { Logs } from '../../logs/'; import { MonitoringTimeseriesContainer } from '../../chart'; import { ShardAllocation } from '../shard_allocation/shard_allocation'; +import { FormattedMessage } from '@kbn/i18n/react'; export const Node = ({ nodeSummary, @@ -38,10 +40,17 @@ export const Node = ({ metrics.node_latency, metrics.node_segment_count, ]; - return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js index 428da4ca913aa..d9cf29f73ce0d 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js @@ -23,10 +23,12 @@ import { EuiCallOut, EuiButton, EuiText, + EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import _ from 'lodash'; import { ELASTICSEARCH_SYSTEM_ID } from '../../../../common/constants'; +import { FormattedMessage } from '@kbn/i18n/react'; import { ListingCallOut } from '../../setup_mode/listing_callout'; const getNodeTooltip = node => { @@ -399,6 +401,14 @@ export function ElasticsearchNodes({ clusterStatus, showCgroupMetricsElasticsear return ( + +

+ +

+
{renderClusterStatus()} {setupModeCallout} diff --git a/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js b/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js index b9cd53ef9fe12..053130076fa77 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js +++ b/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js @@ -13,6 +13,7 @@ import { EuiSpacer, EuiLink, EuiCallOut, + EuiScreenReaderOnly, } from '@elastic/eui'; import { capitalize, get } from 'lodash'; import { ClusterStatus } from '../cluster_status'; @@ -247,6 +248,14 @@ export class KibanaInstances extends PureComponent { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/license/index.js b/x-pack/legacy/plugins/monitoring/public/components/license/index.js index 0e92cda66bc62..75534da6fbef3 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/license/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/license/index.js @@ -15,6 +15,7 @@ import { EuiLink, EuiFlexGroup, EuiFlexItem, + EuiScreenReaderOnly, } from '@elastic/eui'; import { LicenseStatus, AddLicense } from 'plugins/xpack_main/components'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -61,6 +62,11 @@ export function License(props) { const { status, type, isExpired, expiryDate } = props; return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js index 073fc7fc61bcc..7efe586347970 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js @@ -6,7 +6,15 @@ import React, { PureComponent } from 'react'; import { get } from 'lodash'; -import { EuiPage, EuiLink, EuiPageBody, EuiPageContent, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { + EuiPage, + EuiLink, + EuiPageBody, + EuiPageContent, + EuiPanel, + EuiSpacer, + EuiScreenReaderOnly, +} from '@elastic/eui'; import { formatPercentageUsage, formatNumber } from '../../../lib/format_number'; import { ClusterStatus } from '..//cluster_status'; import { EuiMonitoringTable } from '../../table'; @@ -165,6 +173,14 @@ export class Listing extends PureComponent { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/overview/overview.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/overview/overview.js index 9ed644aa043d6..ed9769b460d92 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/overview/overview.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/overview/overview.js @@ -13,9 +13,11 @@ import { EuiSpacer, EuiFlexGrid, EuiFlexItem, + EuiScreenReaderOnly, } from '@elastic/eui'; import { ClusterStatus } from '../cluster_status'; import { MonitoringTimeseriesContainer } from '../../chart'; +import { FormattedMessage } from '@kbn/i18n/react'; export class Overview extends PureComponent { render() { @@ -29,6 +31,14 @@ export class Overview extends PureComponent { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js index 32bd1303cdfa0..72ef7a3b0ec5c 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js @@ -16,12 +16,14 @@ import { EuiSpacer, EuiFlexGroup, EuiFlexItem, + EuiScreenReaderOnly, } from '@elastic/eui'; import { formatMetric } from '../../../lib/format_number'; import { ClusterStatus } from '../cluster_status'; import { Sparkline } from 'plugins/monitoring/components/sparkline'; import { EuiMonitoringSSPTable } from '../../table'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; export class PipelineListing extends Component { tooltipXValueFormatter(xValue, dateFormat) { @@ -148,6 +150,14 @@ export class PipelineListing extends Component { return ( + +

+ +

+
{this.renderStats()} diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/__test__/__snapshots__/pipeline_viewer.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/__test__/__snapshots__/pipeline_viewer.test.js.snap index b19560df7e951..6b46371e0ee50 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/__test__/__snapshots__/pipeline_viewer.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/__test__/__snapshots__/pipeline_viewer.test.js.snap @@ -3,6 +3,15 @@ exports[`PipelineViewer component passes expected props 1`] = ` + +

+ +

+
+ +

+ +

+
-

+

Filters -

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/pipeline_viewer.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/pipeline_viewer.js index d96a9c535dde7..0a3f2d143d273 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/pipeline_viewer.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/pipeline_viewer.js @@ -10,7 +10,8 @@ import { DetailDrawer } from './detail_drawer'; import { Queue } from './queue'; import { StatementSection } from './statement_section'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiPage, EuiPageContent, EuiPageBody } from '@elastic/eui'; +import { EuiSpacer, EuiPage, EuiPageContent, EuiPageBody, EuiScreenReaderOnly } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; export class PipelineViewer extends React.Component { constructor() { @@ -42,6 +43,14 @@ export class PipelineViewer extends React.Component { return ( + +

+ +

+
-

{title}

+

{title}

diff --git a/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap index cf096ed1f7914..95dd580775f31 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap @@ -4,6 +4,11 @@ exports[`NoData should show a default message if reason is unknown 1`] = `
+

+ No monitoring data found. +

+

+ No monitoring data found. +

+ +

+ +

+
+ +

+ +

+
Date: Thu, 9 Jan 2020 17:07:44 +0100 Subject: [PATCH 42/63] =?UTF-8?q?Fix=20eslint=20rule=20for=20vis=5Ftype=5F?= =?UTF-8?q?metric=20`jsx-a11y/click-events-hav=E2=80=A6=20(#54279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.js | 6 ------ .../public/components/metric_vis_value.js | 9 ++++++++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 9719d1b35548e..bdab4e0cebbe5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -94,12 +94,6 @@ module.exports = { 'react-hooks/exhaustive-deps': 'off', }, }, - { - files: ['src/legacy/core_plugins/vis_type_metric/**/*.{js,ts,tsx}'], - rules: { - 'jsx-a11y/click-events-have-key-events': 'off', - }, - }, { files: ['src/legacy/core_plugins/vis_type_table/**/*.{js,ts,tsx}'], rules: { diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.js b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.js index ca5ab20957281..0776dd13a9868 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.js +++ b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.js @@ -21,13 +21,19 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { EuiKeyboardAccessible } from '@elastic/eui'; +import { EuiKeyboardAccessible, keyCodes } from '@elastic/eui'; class MetricVisValue extends Component { onClick = () => { this.props.onFilter(this.props.metric); }; + onKeyPress = e => { + if (e.keyCode === keyCodes.ENTER) { + this.onClick(); + } + }; + render() { const { fontSize, metric, onFilter, showLabel } = this.props; const hasFilter = !!onFilter; @@ -47,6 +53,7 @@ class MetricVisValue extends Component { className={containerClassName} style={{ backgroundColor: metric.bgColor }} onClick={hasFilter ? this.onClick : null} + onKeyPress={hasFilter ? this.onKeyPress : null} tabIndex={hasFilter ? 0 : null} role={hasFilter ? 'button' : null} > From 599a470f544c061a2ebfc1f18a27f6aeeb7f1ca4 Mon Sep 17 00:00:00 2001 From: Pedro Luiz Cabral Salomon Prado Date: Thu, 9 Jan 2020 13:10:55 -0300 Subject: [PATCH 43/63] Added space char (#49997) added space to improve readability Co-authored-by: Elastic Machine --- x-pack/legacy/plugins/canvas/i18n/functions/dict/map_column.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_column.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_column.ts index e16a2ac67a54e..589dd9b1dad87 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_column.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_column.ts @@ -14,7 +14,7 @@ export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.mapColumnHelpText', { defaultMessage: 'Adds a column calculated as the result of other columns. ' + - 'Changes are made only when you provide arguments.' + + 'Changes are made only when you provide arguments. ' + 'See also {mapColumnFn} and {staticColumnFn}.', values: { mapColumnFn: '`mapColumn`', From c2362d48070ff919a9df2b9d2c1e2b6a03726b58 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Thu, 9 Jan 2020 10:13:22 -0700 Subject: [PATCH 44/63] [Reporting] Update some runtime validations (#53975) * [Reporting] Update some runtime validations * fix unit test * i18n * make warning logging of encryptionKey possible * update snapshot * revert unrelated config change --- docs/settings/reporting-settings.asciidoc | 8 +++++ x-pack/legacy/plugins/reporting/config.ts | 2 +- ...e_config.js => validate_encryption_key.js} | 7 ++--- .../__tests__/validate_server_host.ts | 30 +++++++++++++++++++ .../reporting/server/lib/validate/index.ts | 20 ++++++++++--- ...e_config.ts => validate_encryption_key.ts} | 14 +++++++-- .../validate/validate_max_content_length.ts | 1 + .../lib/validate/validate_server_host.ts | 27 +++++++++++++++++ 8 files changed, 97 insertions(+), 12 deletions(-) rename x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/{validate_config.js => validate_encryption_key.js} (79%) create mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts rename x-pack/legacy/plugins/reporting/server/lib/validate/{validate_config.ts => validate_encryption_key.ts} (55%) create mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index a754f91e9f22a..a9fa2bd18d315 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -54,6 +54,14 @@ The protocol for accessing Kibana, typically `http` or `https`. `xpack.reporting.kibanaServer.hostname`:: The hostname for accessing {kib}, if different from the `server.host` value. +[NOTE] +============ +Reporting authenticates requests on the Kibana page only when the hostname matches the +`xpack.reporting.kibanaServer.hostname` setting. Therefore Reporting would fail if the +set value redirects to another server. For that reason, `"0"` is an invalid setting +because, in the Reporting browser, it becomes an automatic redirect to `"0.0.0.0"`. +============ + [float] [[reporting-job-queue-settings]] diff --git a/x-pack/legacy/plugins/reporting/config.ts b/x-pack/legacy/plugins/reporting/config.ts index 8b1d6f6f19805..34fc1f452fbc0 100644 --- a/x-pack/legacy/plugins/reporting/config.ts +++ b/x-pack/legacy/plugins/reporting/config.ts @@ -14,7 +14,7 @@ export async function config(Joi: any) { enabled: Joi.boolean().default(true), kibanaServer: Joi.object({ protocol: Joi.string().valid(['http', 'https']), - hostname: Joi.string(), + hostname: Joi.string().invalid('0'), port: Joi.number().integer(), }).default(), queue: Joi.object({ diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_config.js b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js similarity index 79% rename from x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_config.js rename to x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js index 8b5d6f4591ff5..10980f702d849 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_config.js +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js @@ -6,10 +6,9 @@ import expect from '@kbn/expect'; import sinon from 'sinon'; -import { validateConfig } from '../validate_config'; +import { validateEncryptionKey } from '../validate_encryption_key'; -// FAILING: https://github.com/elastic/kibana/issues/51373 -describe.skip('Reporting: Validate config', () => { +describe('Reporting: Validate config', () => { const logger = { warning: sinon.spy(), }; @@ -25,7 +24,7 @@ describe.skip('Reporting: Validate config', () => { set: sinon.stub(), }; - expect(() => validateConfig(config, logger)).not.to.throwError(); + expect(() => validateEncryptionKey({ config: () => config }, logger)).not.to.throwError(); sinon.assert.calledWith(config.set, 'xpack.reporting.encryptionKey'); sinon.assert.calledWithMatch(logger.warning, /Generating a random key/); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts new file mode 100644 index 0000000000000..04f998fd3e5a5 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import sinon from 'sinon'; +import { ServerFacade } from '../../../../types'; +import { validateServerHost } from '../validate_server_host'; + +const configKey = 'xpack.reporting.kibanaServer.hostname'; + +describe('Reporting: Validate server host setting', () => { + it(`should log a warning and set ${configKey} if server.host is "0"`, () => { + const getStub = sinon.stub(); + getStub.withArgs('server.host').returns('0'); + getStub.withArgs(configKey).returns(undefined); + const config = { + get: getStub, + set: sinon.stub(), + }; + + expect(() => + validateServerHost(({ config: () => config } as unknown) as ServerFacade) + ).to.throwError(); + + sinon.assert.calledWith(config.set, configKey); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts index 672f90358aba4..79a64bd82d022 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { ServerFacade, Logger } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; import { validateBrowser } from './validate_browser'; -import { validateConfig } from './validate_config'; +import { validateEncryptionKey } from './validate_encryption_key'; import { validateMaxContentLength } from './validate_max_content_length'; +import { validateServerHost } from './validate_server_host'; export async function runValidations( server: ServerFacade, @@ -18,13 +20,23 @@ export async function runValidations( try { await Promise.all([ validateBrowser(server, browserFactory, logger), - validateConfig(server, logger), + validateEncryptionKey(server, logger), validateMaxContentLength(server, logger), + validateServerHost(server), ]); - logger.debug(`Reporting plugin self-check ok!`); + logger.debug( + i18n.translate('xpack.reporting.selfCheck.ok', { + defaultMessage: `Reporting plugin self-check ok!`, + }) + ); } catch (err) { logger.warning( - `Reporting plugin self-check failed. Please check the Kibana Reporting settings. ${err}` + i18n.translate('xpack.reporting.selfCheck.warning', { + defaultMessage: `Reporting plugin self-check generated a warning: {err}`, + values: { + err, + }, + }) ); } } diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_config.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts similarity index 55% rename from x-pack/legacy/plugins/reporting/server/lib/validate/validate_config.ts rename to x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts index a1eb7be6ecae4..e0af94cbdc29c 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_config.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts @@ -4,17 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import crypto from 'crypto'; import { ServerFacade, Logger } from '../../../types'; -export function validateConfig(serverFacade: ServerFacade, logger: Logger) { +export function validateEncryptionKey(serverFacade: ServerFacade, logger: Logger) { const config = serverFacade.config(); const encryptionKey = config.get('xpack.reporting.encryptionKey'); if (encryptionKey == null) { + // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. logger.warning( - `Generating a random key for xpack.reporting.encryptionKey. To prevent pending reports from failing on restart, please set ` + - `xpack.reporting.encryptionKey in kibana.yml` + i18n.translate('xpack.reporting.selfCheckEncryptionKey.warning', { + defaultMessage: + `Generating a random key for {setting}. To prevent pending reports ` + + `from failing on restart, please set {setting} in kibana.yml`, + values: { + setting: 'xpack.reporting.encryptionKey', + }, + }) ); // @ts-ignore: No set() method on KibanaConfig, just get() and has() diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts index ca38ce5d635c6..f91cd40bfd3c7 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts @@ -25,6 +25,7 @@ export async function validateMaxContentLength(server: ServerFacade, logger: Log const kibanaMaxContentBytes: number = config.get(KIBANA_MAX_SIZE_BYTES_PATH); if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) { + // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. logger.warning( `${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your ${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts new file mode 100644 index 0000000000000..f4f4d61246b6a --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.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 { ServerFacade } from '../../../types'; + +const configKey = 'xpack.reporting.kibanaServer.hostname'; + +export function validateServerHost(serverFacade: ServerFacade) { + const config = serverFacade.config(); + + const serverHost = config.get('server.host'); + const reportingKibanaHostName = config.get(configKey); + + if (!reportingKibanaHostName && serverHost === '0') { + // @ts-ignore: No set() method on KibanaConfig, just get() and has() + config.set(configKey, '0.0.0.0'); // update config in memory to allow Reporting to work + + throw new Error( + `Found 'server.host: "0"' in settings. This is incompatible with Reporting. ` + + `To enable Reporting to work, '${configKey}: 0.0.0.0' is being automatically to the configuration. ` + + `You can change to 'server.host: 0.0.0.0' or add '${configKey}: 0.0.0.0' in kibana.yml to prevent this message.` + ); + } +} From 31a0bfd540e4780b169c6c9f2329e46e3a50a1cf Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 9 Jan 2020 18:23:44 +0100 Subject: [PATCH 45/63] [Uptime] Monitor details page left side title (#53529) * update API * update query * hide layer control and added loc tags * update test * remove unused comment * update API * remove capitalization * style fix * update types * added location status number on details page * useref instead of createRef * update interface * update import * removed redundant file * fix header for empty data * refactor for most recent check * remove redundant code * remone unused translation * update status bar * update styling * update snaps * added API tests * fix types * fixing integration tests and a typo * remove unused translations * update tests * fixed PR feedback * update feedback * update messaging * update snap Co-authored-by: Elastic Machine --- x-pack/legacy/plugins/uptime/README.md | 4 +- .../uptime/common/graphql/introspection.json | 58 ------------ .../plugins/uptime/common/graphql/types.ts | 22 +---- .../uptime/common/runtime_types/index.ts | 3 +- .../{monitor_details.ts => details.ts} | 1 + .../runtime_types/monitor}/index.ts | 4 +- .../{monitor_locations.ts => locations.ts} | 0 .../empty_status_bar.test.tsx.snap | 29 ------ .../monitor_ssl_certificate.test.tsx.snap | 18 ++-- .../monitor_status.bar.test.tsx.snap | 57 +++++------- .../uptime_date_picker.test.tsx.snap | 44 ++------- .../__tests__/empty_status_bar.test.tsx | 21 ----- .../monitor_ssl_certificate.test.tsx | 2 +- .../__tests__/monitor_status.bar.test.tsx | 61 +++++++++---- .../__tests__/uptime_date_picker.test.tsx | 27 +----- .../__snapshots__/data_missing.test.tsx.snap | 2 +- .../__snapshots__/empty_state.test.tsx.snap | 23 ++--- .../__tests__/data_missing.test.tsx | 2 +- .../__tests__/empty_state.test.tsx | 12 +-- .../functional/empty_state/data_missing.tsx | 83 ++++++++--------- .../functional/empty_state/empty_state.tsx | 5 +- .../functional/empty_status_bar.tsx | 31 ------- .../public/components/functional/index.ts | 4 +- .../embeddables/__tests__/map_config.test.ts | 8 +- .../location_map/embeddables/embedded_map.tsx | 25 ++++-- .../location_map/embeddables/map_config.ts | 3 +- .../functional/location_map/location_map.tsx | 4 +- .../functional/monitor_page_title.tsx | 38 -------- .../monitor_status_bar/monitor_status_bar.tsx | 80 ----------------- .../status_by_location.test.tsx.snap | 51 +++++++++++ .../__test__/status_by_location.test.tsx | 81 +++++++++++++++++ .../monitor_status_details/index.ts | 6 +- .../monitor_status_bar/index.ts | 50 +++++++++++ .../monitor_ssl_certificate.tsx | 44 ++++----- .../monitor_status_bar/monitor_status_bar.tsx | 79 ++++++++++++++++ .../monitor_status_bar/status_by_location.tsx | 70 +++++++++++++++ .../monitor_status_bar/translations.ts | 0 .../monitor_status_details.tsx | 9 +- .../public/components/functional/snapshot.tsx | 2 +- .../functional/uptime_date_picker.tsx | 11 ++- .../contexts/uptime_settings_context.ts | 6 +- .../plugins/uptime/public/pages/index.ts | 1 + .../plugins/uptime/public/pages/monitor.tsx | 49 +++------- .../plugins/uptime/public/pages/overview.tsx | 30 ++----- .../uptime/public/pages/page_header.tsx | 85 ++++++++++++++++++ .../plugins/uptime/public/queries/index.ts | 2 - .../queries/monitor_page_title_query.ts | 20 ----- .../queries/monitor_status_bar_query.ts | 41 --------- .../legacy/plugins/uptime/public/routes.tsx | 32 +++++++ .../uptime/public/state/actions/index.ts | 1 + .../public/state/actions/monitor_status.ts | 15 ++++ .../uptime/public/state/actions/types.ts | 2 + .../plugins/uptime/public/state/api/index.ts | 1 + .../uptime/public/state/api/monitor_status.ts | 49 ++++++++++ .../uptime/public/state/effects/index.ts | 2 + .../public/state/effects/monitor_status.ts | 53 +++++++++++ .../uptime/public/state/reducers/index.ts | 3 +- .../uptime/public/state/reducers/monitor.ts | 4 +- .../public/state/reducers/monitor_status.ts | 67 ++++++++++++++ .../state/selectors/__tests__/index.test.ts | 9 +- .../uptime/public/state/selectors/index.ts | 10 ++- .../plugins/uptime/public/uptime_app.tsx | 59 +++--------- .../server/graphql/monitors/resolvers.ts | 40 --------- .../server/graphql/monitors/schema.gql.ts | 8 -- .../lib/adapters/monitors/adapter_types.ts | 8 +- .../elasticsearch_monitors_adapter.ts | 43 +-------- .../elasticsearch_pings_adapter.test.ts | 21 +++-- .../lib/adapters/pings/adapter_types.ts | 12 +-- .../pings/elasticsearch_pings_adapter.ts | 60 +++++++++---- .../plugins/uptime/server/rest_api/index.ts | 9 +- .../uptime/server/rest_api/monitors/index.ts | 1 + .../uptime/server/rest_api/monitors/status.ts | 60 +++++++++++++ .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../graphql/fixtures/monitor_page_title.json | 7 -- .../fixtures/monitor_status_by_id.json | 20 ----- .../graphql/helpers/expect_fixture_eql.ts | 7 +- .../apis/uptime/graphql/index.js | 2 - .../apis/uptime/graphql/monitor_page_title.ts | 37 -------- .../apis/uptime/graphql/monitor_status_bar.js | 58 ------------ .../rest/fixtures/monitor_latest_status.json | 89 +++++++++++++++++++ .../rest/fixtures/selected_monitor.json | 28 ++++++ .../api_integration/apis/uptime/rest/index.ts | 2 + .../apis/uptime/rest/monitor_latest_status.ts | 25 ++++++ .../apis/uptime/rest/selected_monitor.ts | 23 +++++ 85 files changed, 1202 insertions(+), 977 deletions(-) rename x-pack/legacy/plugins/uptime/common/runtime_types/monitor/{monitor_details.ts => details.ts} (99%) rename x-pack/legacy/plugins/uptime/{public/components/functional/monitor_status_bar => common/runtime_types/monitor}/index.ts (61%) rename x-pack/legacy/plugins/uptime/common/runtime_types/monitor/{monitor_locations.ts => locations.ts} (100%) delete mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/empty_status_bar.test.tsx.snap delete mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/__tests__/empty_status_bar.test.tsx delete mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/empty_status_bar.tsx delete mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_page_title.tsx delete mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_status_bar.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/status_by_location.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/index.ts rename x-pack/legacy/plugins/uptime/public/components/functional/{ => monitor_status_details}/monitor_status_bar/monitor_ssl_certificate.tsx (53%) create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_status_bar.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/status_by_location.tsx rename x-pack/legacy/plugins/uptime/public/components/functional/{ => monitor_status_details}/monitor_status_bar/translations.ts (100%) create mode 100644 x-pack/legacy/plugins/uptime/public/pages/page_header.tsx delete mode 100644 x-pack/legacy/plugins/uptime/public/queries/monitor_page_title_query.ts delete mode 100644 x-pack/legacy/plugins/uptime/public/queries/monitor_status_bar_query.ts create mode 100644 x-pack/legacy/plugins/uptime/public/routes.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts create mode 100644 x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts create mode 100644 x-pack/legacy/plugins/uptime/public/state/effects/monitor_status.ts create mode 100644 x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts create mode 100644 x-pack/legacy/plugins/uptime/server/rest_api/monitors/status.ts delete mode 100644 x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_page_title.json delete mode 100644 x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_status_by_id.json delete mode 100644 x-pack/test/api_integration/apis/uptime/graphql/monitor_page_title.ts delete mode 100644 x-pack/test/api_integration/apis/uptime/graphql/monitor_status_bar.js create mode 100644 x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json create mode 100644 x-pack/test/api_integration/apis/uptime/rest/fixtures/selected_monitor.json create mode 100644 x-pack/test/api_integration/apis/uptime/rest/monitor_latest_status.ts create mode 100644 x-pack/test/api_integration/apis/uptime/rest/selected_monitor.ts diff --git a/x-pack/legacy/plugins/uptime/README.md b/x-pack/legacy/plugins/uptime/README.md index 37e030c44db37..308f78ecdc368 100644 --- a/x-pack/legacy/plugins/uptime/README.md +++ b/x-pack/legacy/plugins/uptime/README.md @@ -3,7 +3,7 @@ ## Purpose The purpose of this plugin is to provide users of Heartbeat more visibility of what's happening -in their infrasturcture. It's primarily built using React and Apollo's GraphQL tools. +in their infrastructure. It's primarily built using React and Apollo's GraphQL tools. ## Layout @@ -44,7 +44,7 @@ From `~/kibana/x-pack`, run `node scripts/jest.js`. ### Functional tests In one shell, from **~/kibana/x-pack**: -`node scripts/functional_tests-server.js` +`node scripts/functional_tests_server.js` In another shell, from **~kibana/x-pack**: `node ../scripts/functional_test_runner.js --grep="{TEST_NAME}"`. diff --git a/x-pack/legacy/plugins/uptime/common/graphql/introspection.json b/x-pack/legacy/plugins/uptime/common/graphql/introspection.json index 29e8435e61b7b..19d9cf19cc7f8 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/introspection.json +++ b/x-pack/legacy/plugins/uptime/common/graphql/introspection.json @@ -352,25 +352,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "getMonitorPageTitle", - "description": "", - "args": [ - { - "name": "monitorId", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - } - ], - "type": { "kind": "OBJECT", "name": "MonitorPageTitle", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "getMonitorStates", "description": "Fetches the current state of Uptime monitors for the given parameters.", @@ -2549,45 +2530,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "OBJECT", - "name": "MonitorPageTitle", - "description": "", - "fields": [ - { - "name": "id", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "url", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, { "kind": "OBJECT", "name": "MonitorSummaryResult", diff --git a/x-pack/legacy/plugins/uptime/common/graphql/types.ts b/x-pack/legacy/plugins/uptime/common/graphql/types.ts index 9998720d5e378..080ce34c09f3c 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/types.ts +++ b/x-pack/legacy/plugins/uptime/common/graphql/types.ts @@ -32,7 +32,6 @@ export interface Query { getFilterBar?: FilterBar | null; - getMonitorPageTitle?: MonitorPageTitle | null; /** Fetches the current state of Uptime monitors for the given parameters. */ getMonitorStates?: MonitorSummaryResult | null; /** Fetches details about the uptime index. */ @@ -484,13 +483,6 @@ export interface FilterBar { urls?: string[] | null; } -export interface MonitorPageTitle { - id: string; - - url?: string | null; - - name?: string | null; -} /** The primary object returned for monitor states. */ export interface MonitorSummaryResult { /** Used to go to the next page of results */ @@ -736,24 +728,12 @@ export interface GetMonitorChartsDataQueryArgs { location?: string | null; } -export interface GetLatestMonitorsQueryArgs { - /** The lower limit of the date range. */ - dateRangeStart: string; - /** The upper limit of the date range. */ - dateRangeEnd: string; - /** Optional: a specific monitor ID filter. */ - monitorId?: string | null; - /** Optional: a specific instance location filter. */ - location?: string | null; -} export interface GetFilterBarQueryArgs { dateRangeStart: string; dateRangeEnd: string; } -export interface GetMonitorPageTitleQueryArgs { - monitorId: string; -} + export interface GetMonitorStatesQueryArgs { dateRangeStart: string; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts index 224892eb91783..c74bc81ae2549 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts @@ -6,5 +6,4 @@ export * from './common'; export * from './snapshot'; -export * from './monitor/monitor_details'; -export * from './monitor/monitor_locations'; +export * from './monitor'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/monitor_details.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/details.ts similarity index 99% rename from x-pack/legacy/plugins/uptime/common/runtime_types/monitor/monitor_details.ts rename to x-pack/legacy/plugins/uptime/common/runtime_types/monitor/details.ts index 0a57a67b898e0..bf81c91bae633 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/monitor_details.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/details.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 * as t from 'io-ts'; // IO type for validation diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/index.ts similarity index 61% rename from x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/index.ts rename to x-pack/legacy/plugins/uptime/common/runtime_types/monitor/index.ts index 7087407407c55..80b48d09dc5b8 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/index.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { MonitorSSLCertificate } from './monitor_ssl_certificate'; -export { MonitorStatusBar, MonitorStatusBarComponent } from './monitor_status_bar'; +export * from './details'; +export * from './locations'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/monitor_locations.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts similarity index 100% rename from x-pack/legacy/plugins/uptime/common/runtime_types/monitor/monitor_locations.ts rename to x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/empty_status_bar.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/empty_status_bar.test.tsx.snap deleted file mode 100644 index e18846e960122..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/empty_status_bar.test.tsx.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EmptyStatusBar component renders a default message when no message provided 1`] = ` - - - - No data found for monitor id mon_id - - - -`; - -exports[`EmptyStatusBar component renders a message when provided 1`] = ` - - - - foobarbaz - - - -`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap index 45c24fd11194d..d731a168225b7 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap @@ -5,22 +5,14 @@ Array [
, - .c0 { - margin-left: 20px; -} - -
-
- SSL certificate expires in 2 months -
+ SSL certificate expires in 2 months
, ] diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_status.bar.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_status.bar.test.tsx.snap index e53235b1ed6f7..17588ae53ed00 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_status.bar.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_status.bar.test.tsx.snap @@ -2,46 +2,24 @@ exports[`MonitorStatusBar component renders duration in ms, not us 1`] = `
-
-
- -
-
- Up -
-
+

+ Up in 2 Locations +

- 1234ms -
-
- 15 minutes ago + +

+ id1 +

+
+
+
`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/uptime_date_picker.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/uptime_date_picker.test.tsx.snap index a38ca85a3e9e7..9a4cb2e04f59b 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/uptime_date_picker.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/uptime_date_picker.test.tsx.snap @@ -227,54 +227,24 @@ exports[`UptimeDatePicker component validates props with shallow render 1`] = ` commonlyUsedRanges={ Array [ Object { - "end": "now/d", + "end": "now", "label": "Today", "start": "now/d", }, - Object { - "end": "now/w", - "label": "This week", - "start": "now/w", - }, - Object { - "end": "now", - "label": "Last 15 minutes", - "start": "now-15m", - }, - Object { - "end": "now", - "label": "Last 30 minutes", - "start": "now-30m", - }, Object { "end": "now", - "label": "Last 1 hour", - "start": "now-1h", - }, - Object { - "end": "now", - "label": "Last 24 hours", - "start": "now-24h", - }, - Object { - "end": "now", - "label": "Last 7 days", - "start": "now-7d", - }, - Object { - "end": "now", - "label": "Last 30 days", - "start": "now-30d", + "label": "Week to date", + "start": "now/w", }, Object { "end": "now", - "label": "Last 90 days", - "start": "now-90d", + "label": "Month to date", + "start": "now/M", }, Object { "end": "now", - "label": "Last 2 year", - "start": "now-1y", + "label": "Year to date", + "start": "now/y", }, ] } diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/empty_status_bar.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/empty_status_bar.test.tsx deleted file mode 100644 index b815f0e38b8e2..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/empty_status_bar.test.tsx +++ /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 React from 'react'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { EmptyStatusBar } from '../empty_status_bar'; - -describe('EmptyStatusBar component', () => { - it('renders a message when provided', () => { - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); - }); - - it('renders a default message when no message provided', () => { - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx index c4063208d046e..03eb252aa8c09 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import moment from 'moment'; import { renderWithIntl } from 'test_utils/enzyme_helpers'; import { PingTls } from '../../../../common/graphql/types'; -import { MonitorSSLCertificate } from '../monitor_status_bar'; +import { MonitorSSLCertificate } from '../monitor_status_details/monitor_status_bar'; describe('MonitorStatusBar component', () => { let monitorTls: PingTls; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_status.bar.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_status.bar.test.tsx index 761dd8a65238f..545405f91d537 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_status.bar.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_status.bar.test.tsx @@ -8,34 +8,59 @@ import moment from 'moment'; import React from 'react'; import { renderWithIntl } from 'test_utils/enzyme_helpers'; import { Ping } from '../../../../common/graphql/types'; -import { MonitorStatusBarComponent } from '../monitor_status_bar'; +import { MonitorStatusBarComponent } from '../monitor_status_details/monitor_status_bar'; describe('MonitorStatusBar component', () => { - let monitorStatus: Ping[]; + let monitorStatus: Ping; + let monitorLocations: any; + let dateStart: string; + let dateEnd: string; beforeEach(() => { - monitorStatus = [ - { - id: 'id1', - timestamp: moment(new Date()) - .subtract(15, 'm') - .toString(), - monitor: { - duration: { - us: 1234567, - }, - status: 'up', - }, - url: { - full: 'https://www.example.com/', + monitorStatus = { + id: 'id1', + timestamp: moment(new Date()) + .subtract(15, 'm') + .toString(), + monitor: { + duration: { + us: 1234567, }, + status: 'up', + }, + url: { + full: 'https://www.example.com/', }, - ]; + }; + + monitorLocations = { + monitorId: 'secure-avc', + locations: [ + { + summary: { up: 4, down: 0 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + { + summary: { up: 4, down: 0 }, + geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ], + }; + + dateStart = moment('01-01-2010').toString(); + dateEnd = moment('10-10-2010').toString(); }); it('renders duration in ms, not us', () => { const component = renderWithIntl( - + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx index 93fa0b505a891..e3ca1a87850c8 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx @@ -6,37 +6,16 @@ import { shallowWithIntl, renderWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; -import { UptimeDatePicker, CommonlyUsedRange } from '../uptime_date_picker'; +import { UptimeDatePicker } from '../uptime_date_picker'; describe('UptimeDatePicker component', () => { - let commonlyUsedRange: CommonlyUsedRange[]; - - beforeEach(() => { - commonlyUsedRange = [ - { from: 'now/d', to: 'now/d', display: 'Today' }, - { from: 'now/w', to: 'now/w', display: 'This week' }, - { from: 'now-15m', to: 'now', display: 'Last 15 minutes' }, - { from: 'now-30m', to: 'now', display: 'Last 30 minutes' }, - { from: 'now-1h', to: 'now', display: 'Last 1 hour' }, - { from: 'now-24h', to: 'now', display: 'Last 24 hours' }, - { from: 'now-7d', to: 'now', display: 'Last 7 days' }, - { from: 'now-30d', to: 'now', display: 'Last 30 days' }, - { from: 'now-90d', to: 'now', display: 'Last 90 days' }, - { from: 'now-1y', to: 'now', display: 'Last 2 year' }, - ]; - }); - it('validates props with shallow render', () => { - const component = shallowWithIntl( - - ); + const component = shallowWithIntl(); expect(component).toMatchSnapshot(); }); it('renders properly with mock data', () => { - const component = renderWithIntl( - - ); + const component = renderWithIntl(); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap index 4dfc837c29b55..36c54758cf116 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap @@ -20,7 +20,7 @@ exports[`DataMissing component renders basePath and headingMessage 1`] = ` values={ Object { "configureHeartbeatLink":
@@ -301,7 +299,6 @@ exports[`EmptyState component does not render empty state with appropriate base exports[`EmptyState component doesn't render child components when count is falsey 1`] = ` @@ -783,7 +778,6 @@ exports[`EmptyState component renders child components when count is truthy 1`] exports[`EmptyState component renders error message when an error occurs 1`] = ` { it('renders basePath and headingMessage', () => { - const component = shallowWithIntl(); + const component = shallowWithIntl(); expect(component).toMatchSnapshot(); }); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx index 0077292d91a46..32d55519acf27 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx @@ -24,7 +24,7 @@ describe('EmptyState component', () => { it('renders child components when count is truthy', () => { const component = shallowWithIntl( - +
Foo
Bar
Baz
@@ -35,7 +35,7 @@ describe('EmptyState component', () => { it(`doesn't render child components when count is falsey`, () => { const component = mountWithIntl( - +
Shouldn't be rendered
); @@ -57,7 +57,7 @@ describe('EmptyState component', () => { }, ]; const component = mountWithIntl( - +
Shouldn't appear...
); @@ -66,7 +66,7 @@ describe('EmptyState component', () => { it('renders loading state if no errors or doc count', () => { const component = mountWithIntl( - +
Should appear even while loading...
); @@ -81,7 +81,7 @@ describe('EmptyState component', () => { indexExists: true, }; const component = mountWithIntl( - +
If this is in the snapshot the test should fail
); @@ -91,7 +91,7 @@ describe('EmptyState component', () => { it('notifies when index does not exist', () => { statesIndexStatus.indexExists = false; const component = mountWithIntl( - +
This text should not render
); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/data_missing.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/data_missing.tsx index 023edec023ddf..f8110953f6146 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/data_missing.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/data_missing.tsx @@ -14,48 +14,51 @@ import { EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; +import React, { useContext } from 'react'; +import { UptimeSettingsContext } from '../../../contexts'; interface DataMissingProps { - basePath: string; headingMessage: string; } -export const DataMissing = ({ basePath, headingMessage }: DataMissingProps) => ( - - - - - -

{headingMessage}

- - } - body={ -

- - - - ), - }} - /> -

- } - /> -
-
-
-); +export const DataMissing = ({ headingMessage }: DataMissingProps) => { + const { basePath } = useContext(UptimeSettingsContext); + return ( + + + + + +

{headingMessage}

+ + } + body={ +

+ + + + ), + }} + /> +

+ } + /> +
+
+
+ ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx index 2a39226c5b746..d2d46dff3b9f5 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx @@ -18,13 +18,12 @@ interface EmptyStateQueryResult { } interface EmptyStateProps { - basePath: string; children: JSX.Element[] | JSX.Element; } type Props = UptimeGraphQLQueryProps & EmptyStateProps; -export const EmptyStateComponent = ({ basePath, children, data, errors }: Props) => { +export const EmptyStateComponent = ({ children, data, errors }: Props) => { if (errors) { return ; } @@ -33,7 +32,6 @@ export const EmptyStateComponent = ({ basePath, children, data, errors }: Props) if (!indexExists) { return ( ( - - - - {!message - ? i18n.translate('xpack.uptime.emptyStatusBar.defaultMessage', { - defaultMessage: 'No data found for monitor id {monitorId}', - description: - 'This is the default message we display in a status bar when there is no data available for an uptime monitor.', - values: { monitorId }, - }) - : message} - - - -); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts index 42316fca99f34..7370faa12f393 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts @@ -6,14 +6,12 @@ export { DonutChart } from './charts/donut_chart'; export { EmptyState } from './empty_state'; -export { EmptyStatusBar } from './empty_status_bar'; +export { MonitorStatusBar } from './monitor_status_details'; export { FilterGroup } from './filter_group'; export { IntegrationLink } from './integration_link'; export { KueryBar } from './kuery_bar'; export { MonitorCharts } from './monitor_charts'; export { MonitorList } from './monitor_list'; -export { MonitorPageTitle } from './monitor_page_title'; -export { MonitorStatusBar } from './monitor_status_bar'; export { OverviewPageParsingErrorCallout } from './overview_page_parsing_error_callout'; export { PingList } from './ping_list'; export { Snapshot } from './snapshot'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/__tests__/map_config.test.ts b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/__tests__/map_config.test.ts index 263c3fc787da9..7d53d784ff338 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/__tests__/map_config.test.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/__tests__/map_config.test.ts @@ -7,6 +7,7 @@ import { getLayerList } from '../map_config'; import { mockLayerList } from './__mocks__/mock'; import { LocationPoint } from '../embedded_map'; +import { UptimeAppColors } from '../../../../../uptime_app'; jest.mock('uuid', () => { return { @@ -17,6 +18,7 @@ jest.mock('uuid', () => { describe('map_config', () => { let upPoints: LocationPoint[]; let downPoints: LocationPoint[]; + let colors: Pick; beforeEach(() => { upPoints = [ @@ -29,11 +31,15 @@ describe('map_config', () => { { lat: '55.487239', lon: '13.399262' }, { lat: '54.487239', lon: '14.399262' }, ]; + colors = { + danger: '#BC261E', + gray: '#000', + }; }); describe('#getLayerList', () => { test('it returns the low poly layer', () => { - const layerList = getLayerList(upPoints, downPoints, { danger: '#BC261E', gray: '#000' }); + const layerList = getLayerList(upPoints, downPoints, colors); expect(layerList).toStrictEqual(mockLayerList); }); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/embedded_map.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/embedded_map.tsx index fe8a1a0bad7ec..9b20651fadb86 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/embedded_map.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/embedded_map.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState, useContext } from 'react'; +import React, { useEffect, useState, useContext, useRef } from 'react'; import uuid from 'uuid'; import styled from 'styled-components'; @@ -48,19 +48,31 @@ const EmbeddedPanel = styled.div` export const EmbeddedMap = ({ upPoints, downPoints }: EmbeddedMapProps) => { const { colors } = useContext(UptimeSettingsContext); const [embeddable, setEmbeddable] = useState(); - const embeddableRoot: React.RefObject = React.createRef(); + const embeddableRoot: React.RefObject = useRef(null); const factory = start.getEmbeddableFactory(MAP_SAVED_OBJECT_TYPE); const input = { id: uuid.v4(), filters: [], hidePanelTitles: true, - query: { query: '', language: 'kuery' }, - refreshConfig: { value: 0, pause: false }, + query: { + query: '', + language: 'kuery', + }, + refreshConfig: { + value: 0, + pause: false, + }, viewMode: 'view', isLayerTOCOpen: false, hideFilterActions: true, - mapCenter: { lon: 11, lat: 20, zoom: 0 }, + // Zoom Lat/Lon values are set to make sure map is in center in the panel + // It wil also omit Greenland/Antarctica etc + mapCenter: { + lon: 11, + lat: 20, + zoom: 0, + }, disableInteractive: true, disableTooltipControl: true, hideToolbarOverlay: true, @@ -80,16 +92,19 @@ export const EmbeddedMap = ({ upPoints, downPoints }: EmbeddedMapProps) => { setEmbeddable(embeddableObject); } setupEmbeddable(); + // we want this effect to execute exactly once after the component mounts // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // update map layers based on points useEffect(() => { if (embeddable) { embeddable.setLayerList(getLayerList(upPoints, downPoints, colors)); } }, [upPoints, downPoints, embeddable, colors]); + // We can only render after embeddable has already initialized useEffect(() => { if (embeddableRoot.current && embeddable) { embeddable.render(embeddableRoot.current); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/map_config.ts b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/map_config.ts index b423b8baf41bf..d4601baefdf30 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/map_config.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/map_config.ts @@ -6,6 +6,7 @@ import lowPolyLayerFeatures from './low_poly_layer.json'; import { LocationPoint } from './embedded_map'; +import { UptimeAppColors } from '../../../../uptime_app'; /** * Returns `Source/Destination Point-to-point` Map LayerList configuration, with a source, @@ -15,7 +16,7 @@ import { LocationPoint } from './embedded_map'; export const getLayerList = ( upPoints: LocationPoint[], downPoints: LocationPoint[], - { gray, danger }: { gray: string; danger: string } + { gray, danger }: Pick ) => { return [getLowPolyLayer(), getDownPointsLayer(downPoints, danger), getUpPointsLayer(upPoints)]; }; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx index f70d145ec05c3..9a9bf3fe71dc1 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx @@ -11,10 +11,12 @@ import { LocationStatusTags } from './location_status_tags'; import { EmbeddedMap, LocationPoint } from './embeddables/embedded_map'; import { MonitorLocations } from '../../../../common/runtime_types'; +// These height/width values are used to make sure map is in center of panel +// And to make sure, it doesn't take too much space const MapPanel = styled.div` height: 240px; width: 520px; - margin-right: 10px; + margin-right: 20px; `; interface LocationMapProps { diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_page_title.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_page_title.tsx deleted file mode 100644 index 9e30a3adbd776..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_page_title.tsx +++ /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. - */ - -import { EuiTextColor, EuiTitle } from '@elastic/eui'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import React from 'react'; -import { MonitorPageTitle as TitleType } from '../../../common/graphql/types'; -import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../higher_order'; -import { monitorPageTitleQuery } from '../../queries'; - -interface MonitorPageTitleQueryResult { - monitorPageTitle?: TitleType; -} - -interface MonitorPageTitleProps { - monitorId: string; -} - -type Props = MonitorPageTitleProps & UptimeGraphQLQueryProps; - -export const MonitorPageTitleComponent = ({ data }: Props) => - data && data.monitorPageTitle ? ( - - -

{data.monitorPageTitle.id}

-
-
- ) : ( - - ); - -export const MonitorPageTitle = withUptimeGraphQL< - MonitorPageTitleQueryResult, - MonitorPageTitleProps ->(MonitorPageTitleComponent, monitorPageTitleQuery); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_status_bar.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_status_bar.tsx deleted file mode 100644 index f36f0dff6745f..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_status_bar.tsx +++ /dev/null @@ -1,80 +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 { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiLink } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { get } from 'lodash'; -import moment from 'moment'; -import React from 'react'; -import { Ping } from '../../../../common/graphql/types'; -import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../../higher_order'; -import { monitorStatusBarQuery } from '../../../queries'; -import { EmptyStatusBar } from '../empty_status_bar'; -import { convertMicrosecondsToMilliseconds } from '../../../lib/helper'; -import { MonitorSSLCertificate } from './monitor_ssl_certificate'; -import * as labels from './translations'; - -interface MonitorStatusBarQueryResult { - monitorStatus?: Ping[]; -} - -interface MonitorStatusBarProps { - monitorId: string; -} - -type Props = MonitorStatusBarProps & UptimeGraphQLQueryProps; - -export const MonitorStatusBarComponent = ({ data, monitorId }: Props) => { - if (data?.monitorStatus?.length) { - const { monitor, timestamp, tls } = data.monitorStatus[0]; - const duration: number | undefined = get(monitor, 'duration.us', undefined); - const status = get<'up' | 'down'>(monitor, 'status', 'down'); - const full = get(data.monitorStatus[0], 'url.full'); - - return ( - <> - - - - {status === 'up' ? labels.upLabel : labels.downLabel} - - - - - - {full} - - - - {!!duration && ( - - - - )} - - {moment(new Date(timestamp).valueOf()).fromNow()} - - - - - ); - } - return ; -}; - -export const MonitorStatusBar = withUptimeGraphQL< - MonitorStatusBarQueryResult, - MonitorStatusBarProps ->(MonitorStatusBarComponent, monitorStatusBarQuery); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/status_by_location.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/status_by_location.test.tsx.snap new file mode 100644 index 0000000000000..c6b18501ffa0f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/status_by_location.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StatusByLocation component renders all locations are down 1`] = ` +
+

+ Down in 2 Locations +

+
+`; + +exports[`StatusByLocation component renders when down in some locations 1`] = ` +
+

+ Down in 1/2 Locations +

+
+`; + +exports[`StatusByLocation component renders when only one location and it is down 1`] = ` +
+

+ Down in 1 Location +

+
+`; + +exports[`StatusByLocation component renders when only one location and it is up 1`] = ` +
+

+ Up in 1 Location +

+
+`; + +exports[`StatusByLocation component renders when up in all locations 1`] = ` +
+

+ Up in 2 Locations +

+
+`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx new file mode 100644 index 0000000000000..4e515a52b8de6 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx @@ -0,0 +1,81 @@ +/* + * 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 { renderWithIntl } from 'test_utils/enzyme_helpers'; +import { MonitorLocation } from '../../../../../common/runtime_types'; +import { StatusByLocations } from '../'; + +describe('StatusByLocation component', () => { + let monitorLocations: MonitorLocation[]; + + it('renders when up in all locations', () => { + monitorLocations = [ + { + summary: { up: 4, down: 0 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + { + summary: { up: 4, down: 0 }, + geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when only one location and it is up', () => { + monitorLocations = [ + { + summary: { up: 4, down: 0 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when only one location and it is down', () => { + monitorLocations = [ + { + summary: { up: 0, down: 4 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders all locations are down', () => { + monitorLocations = [ + { + summary: { up: 0, down: 4 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + { + summary: { up: 0, down: 4 }, + geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when down in some locations', () => { + monitorLocations = [ + { + summary: { up: 0, down: 4 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + { + summary: { up: 4, down: 0 }, + geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/index.ts index 234586e0b51f1..7b4e1ea353c11 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/index.ts @@ -5,12 +5,12 @@ */ import { connect } from 'react-redux'; import { AppState } from '../../../state'; -import { getMonitorLocations } from '../../../state/selectors'; +import { selectMonitorLocations } from '../../../state/selectors'; import { fetchMonitorLocations } from '../../../state/actions/monitor'; import { MonitorStatusDetailsComponent } from './monitor_status_details'; const mapStateToProps = (state: AppState, { monitorId }: any) => ({ - monitorLocations: getMonitorLocations(state, monitorId), + monitorLocations: selectMonitorLocations(state, monitorId), }); const mapDispatchToProps = (dispatch: any, ownProps: any) => ({ @@ -32,3 +32,5 @@ export const MonitorStatusDetails = connect( )(MonitorStatusDetailsComponent); export * from './monitor_status_details'; +export { MonitorStatusBar } from './monitor_status_bar'; +export { StatusByLocations } from './monitor_status_bar/status_by_location'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/index.ts new file mode 100644 index 0000000000000..94bd7fa7f026b --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/index.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; +import { + StateProps, + DispatchProps, + MonitorStatusBarComponent, + MonitorStatusBarProps, +} from './monitor_status_bar'; +import { selectMonitorStatus, selectMonitorLocations } from '../../../../state/selectors'; +import { AppState } from '../../../../state'; +import { getMonitorStatus, getSelectedMonitor } from '../../../../state/actions'; + +const mapStateToProps = (state: AppState, ownProps: MonitorStatusBarProps) => ({ + monitorStatus: selectMonitorStatus(state), + monitorLocations: selectMonitorLocations(state, ownProps.monitorId), +}); + +const mapDispatchToProps = (dispatch: Dispatch, ownProps: MonitorStatusBarProps) => ({ + loadMonitorStatus: () => { + const { dateStart, dateEnd, monitorId } = ownProps; + dispatch( + getMonitorStatus({ + monitorId, + dateStart, + dateEnd, + }) + ); + dispatch( + getSelectedMonitor({ + monitorId, + }) + ); + }, +}); + +// @ts-ignore TODO: Investigate typescript issues here +export const MonitorStatusBar = connect( + // @ts-ignore TODO: Investigate typescript issues here + mapStateToProps, + mapDispatchToProps +)(MonitorStatusBarComponent); + +export { MonitorSSLCertificate } from './monitor_ssl_certificate'; +export * from './monitor_status_bar'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_ssl_certificate.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx similarity index 53% rename from x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_ssl_certificate.tsx rename to x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx index c50f7f1b00f0d..5e916c40e712d 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_ssl_certificate.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx @@ -10,9 +10,8 @@ import moment from 'moment'; import { EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; -import { PingTls } from '../../../../common/graphql/types'; +import { PingTls } from '../../../../../common/graphql/types'; interface Props { /** @@ -21,10 +20,6 @@ interface Props { tls: PingTls | null | undefined; } -const TextContainer = styled.div` - margin-left: 20px; -`; - export const MonitorSSLCertificate = ({ tls }: Props) => { const certificateValidity: string | undefined = get( tls, @@ -37,27 +32,22 @@ export const MonitorSSLCertificate = ({ tls }: Props) => { return validExpiryDate && certificateValidity ? ( <> - - - - - + + + ) : null; }; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_status_bar.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_status_bar.tsx new file mode 100644 index 0000000000000..57ca909ffde55 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_status_bar.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiLink, + EuiTitle, + EuiTextColor, + EuiSpacer, + EuiText, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import React, { useEffect } from 'react'; +import { MonitorSSLCertificate } from './monitor_ssl_certificate'; +import * as labels from './translations'; +import { StatusByLocations } from './status_by_location'; +import { Ping } from '../../../../../common/graphql/types'; +import { MonitorLocations } from '../../../../../common/runtime_types'; + +export interface StateProps { + monitorStatus: Ping; + monitorLocations: MonitorLocations; +} + +export interface DispatchProps { + loadMonitorStatus: () => void; +} + +export interface MonitorStatusBarProps { + monitorId: string; + dateStart: string; + dateEnd: string; +} + +type Props = MonitorStatusBarProps & StateProps & DispatchProps; + +export const MonitorStatusBarComponent: React.FC = ({ + dateStart, + dateEnd, + monitorId, + loadMonitorStatus, + monitorStatus, + monitorLocations, +}) => { + useEffect(() => { + loadMonitorStatus(); + }, [dateStart, dateEnd, loadMonitorStatus]); + + const full = monitorStatus?.url?.full ?? ''; + + return ( + + + + + + + + {full} + + + + + + +

{monitorId}

+
+
+
+ + + + +
+ ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/status_by_location.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/status_by_location.tsx new file mode 100644 index 0000000000000..461ffc10124fd --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/status_by_location.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { MonitorLocation } from '../../../../../common/runtime_types'; + +interface StatusByLocationsProps { + locations: MonitorLocation[]; +} + +export const StatusByLocations = ({ locations }: StatusByLocationsProps) => { + const upLocations: string[] = []; + const downLocations: string[] = []; + + if (locations) + locations.forEach((item: any) => { + if (item.summary.down === 0) { + upLocations.push(item.geo.name); + } else { + downLocations.push(item.geo.name); + } + }); + + let statusMessage = ''; + let status = ''; + if (downLocations.length === 0) { + // for Messaging like 'Up in 1 Location' or 'Up in 2 Locations' + statusMessage = `${locations.length}`; + status = 'Up'; + } else if (downLocations.length > 0) { + // for Messaging like 'Down in 1/2 Locations' + status = 'Down'; + statusMessage = `${downLocations.length}/${locations.length}`; + if (downLocations.length === locations.length) { + // for Messaging like 'Down in 2 Locations' + statusMessage = `${locations.length}`; + } + } + + return ( + +

+ {locations.length <= 1 ? ( + + ) : ( + + )} +

+
+ ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/translations.ts b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/translations.ts similarity index 100% rename from x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/translations.ts rename to x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/translations.ts diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_details.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_details.tsx index ed67c6364e958..bb87497d335ef 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_details.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_details.tsx @@ -7,7 +7,7 @@ import React, { useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { LocationMap } from '../location_map'; -import { MonitorStatusBar } from '../monitor_status_bar'; +import { MonitorStatusBar } from './monitor_status_bar'; interface MonitorStatusBarProps { monitorId: string; @@ -34,7 +34,12 @@ export const MonitorStatusDetailsComponent = ({ - + diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/snapshot.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/snapshot.tsx index 4c1b482b198af..90d716001cff9 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/snapshot.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/snapshot.tsx @@ -43,7 +43,7 @@ interface StoreProps { /** * Contains functions that will dispatch actions used - * for this component's lifecyclel + * for this component's life cycle */ interface DispatchProps { loadSnapshotCount: typeof fetchSnapshotCount; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/uptime_date_picker.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/uptime_date_picker.tsx index ebd0cd1e4ae85..c282ac9b9e155 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/uptime_date_picker.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/uptime_date_picker.tsx @@ -5,9 +5,10 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import React from 'react'; +import React, { useContext } from 'react'; import { useUrlParams } from '../../hooks'; import { CLIENT_DEFAULTS } from '../../../common/constants'; +import { UptimeSettingsContext } from '../../contexts'; // TODO: when EUI exports types for this, this should be replaced interface SuperDateRangePickerRangeChangedEvent { @@ -26,16 +27,14 @@ export interface CommonlyUsedRange { display: string; } -interface Props { +interface UptimeDatePickerProps { refreshApp: () => void; - commonlyUsedRanges?: CommonlyUsedRange[]; } -type UptimeDatePickerProps = Props; - -export const UptimeDatePicker = ({ refreshApp, commonlyUsedRanges }: UptimeDatePickerProps) => { +export const UptimeDatePicker = ({ refreshApp }: UptimeDatePickerProps) => { const [getUrlParams, updateUrl] = useUrlParams(); const { autorefreshInterval, autorefreshIsPaused, dateRangeStart, dateRangeEnd } = getUrlParams(); + const { commonlyUsedRanges } = useContext(UptimeSettingsContext); const euiCommonlyUsedRanges = commonlyUsedRanges ? commonlyUsedRanges.map( diff --git a/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.ts b/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.ts index 0fd9be952ed40..c656391678aa2 100644 --- a/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.ts +++ b/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.ts @@ -9,6 +9,7 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { createContext } from 'react'; import { UptimeAppColors } from '../uptime_app'; import { CONTEXT_DEFAULTS } from '../../common/constants'; +import { CommonlyUsedRange } from '../components/functional/uptime_date_picker'; export interface UMSettingsContextValues { absoluteStartDate: number; @@ -23,7 +24,7 @@ export interface UMSettingsContextValues { isInfraAvailable: boolean; isLogsAvailable: boolean; refreshApp: () => void; - setHeadingText: (text: string) => void; + commonlyUsedRanges?: CommonlyUsedRange[]; } const { @@ -64,9 +65,6 @@ const defaultContext: UMSettingsContextValues = { refreshApp: () => { throw new Error('App refresh was not initialized, set it when you invoke the context'); }, - setHeadingText: () => { - throw new Error('setHeadingText was not initialized on UMSettingsContext.'); - }, }; export const UptimeSettingsContext = createContext(defaultContext); diff --git a/x-pack/legacy/plugins/uptime/public/pages/index.ts b/x-pack/legacy/plugins/uptime/public/pages/index.ts index bf5d55ebf17ae..a96be42eb0dee 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/index.ts +++ b/x-pack/legacy/plugins/uptime/public/pages/index.ts @@ -7,3 +7,4 @@ export { MonitorPage } from './monitor'; export { OverviewPage } from './overview'; export { NotFoundPage } from './not_found'; +export { PageHeader } from './page_header'; diff --git a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx index c8334b2376bc1..1b4ad8d82ead1 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx @@ -5,60 +5,31 @@ */ import { EuiSpacer } from '@elastic/eui'; -import { ApolloQueryResult, OperationVariables, QueryOptions } from 'apollo-client'; -import gql from 'graphql-tag'; -import React, { Fragment, useContext, useEffect, useState } from 'react'; -import { getMonitorPageBreadcrumb } from '../breadcrumbs'; -import { MonitorCharts, MonitorPageTitle, PingList } from '../components/functional'; +import React, { Fragment, useContext, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { MonitorCharts, PingList } from '../components/functional'; import { UMUpdateBreadcrumbs } from '../lib/lib'; import { UptimeSettingsContext } from '../contexts'; import { useUptimeTelemetry, useUrlParams, UptimePage } from '../hooks'; -import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { useTrackPageview } from '../../../infra/public'; -import { getTitle } from '../lib/helper/get_title'; import { MonitorStatusDetails } from '../components/functional/monitor_status_details'; +import { PageHeader } from './page_header'; interface MonitorPageProps { - match: { params: { monitorId: string } }; - // this is the query function provided by Apollo's Client API - query: ( - options: QueryOptions - ) => Promise>; setBreadcrumbs: UMUpdateBreadcrumbs; } -export const MonitorPage = ({ query, setBreadcrumbs, match }: MonitorPageProps) => { +export const MonitorPage = ({ setBreadcrumbs }: MonitorPageProps) => { // decode 64 base string, it was decoded to make it a valid url, since monitor id can be a url - const monitorId = atob(match.params.monitorId); + let { monitorId } = useParams(); + monitorId = atob(monitorId || ''); + const [pingListPageCount, setPingListPageCount] = useState(10); - const { colors, refreshApp, setHeadingText } = useContext(UptimeSettingsContext); + const { colors, refreshApp } = useContext(UptimeSettingsContext); const [getUrlParams, updateUrlParams] = useUrlParams(); const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); const { dateRangeStart, dateRangeEnd, selectedPingStatus } = params; - useEffect(() => { - query({ - query: gql` - query MonitorPageTitle($monitorId: String!) { - monitorPageTitle: getMonitorPageTitle(monitorId: $monitorId) { - id - url - name - } - } - `, - variables: { monitorId }, - }).then((result: any) => { - const { name, url, id } = result.data.monitorPageTitle; - const heading: string = name || url || id; - document.title = getTitle(name); - setBreadcrumbs(getMonitorPageBreadcrumb(heading, stringifyUrlParams(params))); - if (setHeadingText) { - setHeadingText(heading); - } - }); - }, [monitorId, params, query, setBreadcrumbs, setHeadingText]); - const [selectedLocation, setSelectedLocation] = useState(undefined); const sharedVariables = { @@ -75,7 +46,7 @@ export const MonitorPage = ({ query, setBreadcrumbs, match }: MonitorPageProps) return ( - + ; - history: any; - location: { - pathname: string; - search: string; - }; setBreadcrumbs: UMUpdateBreadcrumbs; } @@ -52,8 +45,8 @@ const EuiFlexItemStyled = styled(EuiFlexItem)` } `; -export const OverviewPage = ({ basePath, autocomplete, setBreadcrumbs }: Props) => { - const { colors, setHeadingText } = useContext(UptimeSettingsContext); +export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => { + const { colors } = useContext(UptimeSettingsContext); const [getUrlParams, updateUrl] = useUrlParams(); const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); const { @@ -68,18 +61,6 @@ export const OverviewPage = ({ basePath, autocomplete, setBreadcrumbs }: Props) useUptimeTelemetry(UptimePage.Overview); useIndexPattern(setIndexPattern); - useEffect(() => { - setBreadcrumbs(getOverviewPageBreadcrumbs()); - if (setHeadingText) { - setHeadingText( - i18n.translate('xpack.uptime.overviewPage.headerText', { - defaultMessage: 'Overview', - description: `The text that will be displayed in the app's heading when the Overview page loads.`, - }) - ); - } - }, [basePath, setBreadcrumbs, setHeadingText]); - useTrackPageview({ app: 'uptime', path: 'overview' }); useTrackPageview({ app: 'uptime', path: 'overview', delay: 15000 }); @@ -121,7 +102,8 @@ export const OverviewPage = ({ basePath, autocomplete, setBreadcrumbs }: Props) return ( - + + diff --git a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx new file mode 100644 index 0000000000000..250dacb8914e7 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React, { useEffect, useState, useContext } from 'react'; +import { connect } from 'react-redux'; +import { useRouteMatch, useParams } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { UptimeDatePicker } from '../components/functional/uptime_date_picker'; +import { AppState } from '../state'; +import { selectSelectedMonitor } from '../state/selectors'; +import { getMonitorPageBreadcrumb, getOverviewPageBreadcrumbs } from '../breadcrumbs'; +import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; +import { UptimeSettingsContext } from '../contexts'; +import { getTitle } from '../lib/helper/get_title'; +import { UMUpdateBreadcrumbs } from '../lib/lib'; +import { MONITOR_ROUTE } from '../routes'; + +interface PageHeaderProps { + monitorStatus?: any; + setBreadcrumbs: UMUpdateBreadcrumbs; +} + +export const PageHeaderComponent = ({ monitorStatus, setBreadcrumbs }: PageHeaderProps) => { + const monitorPage = useRouteMatch({ + path: MONITOR_ROUTE, + }); + const { refreshApp } = useContext(UptimeSettingsContext); + + const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = useParams(); + + const headingText = i18n.translate('xpack.uptime.overviewPage.headerText', { + defaultMessage: 'Overview', + description: `The text that will be displayed in the app's heading when the Overview page loads.`, + }); + + const [headerText, setHeaderText] = useState(headingText); + + useEffect(() => { + if (monitorPage) { + setHeaderText(monitorStatus?.url?.full); + if (monitorStatus?.monitor) { + const { name, id } = monitorStatus.monitor; + document.title = getTitle(name || id); + } + } else { + document.title = getTitle(); + } + }, [monitorStatus, monitorPage, setHeaderText]); + + useEffect(() => { + if (monitorPage) { + if (headerText) { + setBreadcrumbs(getMonitorPageBreadcrumb(headerText, stringifyUrlParams(params))); + } + } else { + setBreadcrumbs(getOverviewPageBreadcrumbs()); + } + }, [headerText, setBreadcrumbs, params, monitorPage]); + + return ( + <> + + + +

{headerText}

+
+
+ + + +
+ + + ); +}; + +const mapStateToProps = (state: AppState) => ({ + monitorStatus: selectSelectedMonitor(state), +}); + +export const PageHeader = connect(mapStateToProps, null)(PageHeaderComponent); diff --git a/x-pack/legacy/plugins/uptime/public/queries/index.ts b/x-pack/legacy/plugins/uptime/public/queries/index.ts index b86522c03aba8..f4947f5325450 100644 --- a/x-pack/legacy/plugins/uptime/public/queries/index.ts +++ b/x-pack/legacy/plugins/uptime/public/queries/index.ts @@ -7,6 +7,4 @@ export { docCountQuery, docCountQueryString } from './doc_count_query'; export { filterBarQuery, filterBarQueryString } from './filter_bar_query'; export { monitorChartsQuery, monitorChartsQueryString } from './monitor_charts_query'; -export { monitorPageTitleQuery } from './monitor_page_title_query'; -export { monitorStatusBarQuery, monitorStatusBarQueryString } from './monitor_status_bar_query'; export { pingsQuery, pingsQueryString } from './pings_query'; diff --git a/x-pack/legacy/plugins/uptime/public/queries/monitor_page_title_query.ts b/x-pack/legacy/plugins/uptime/public/queries/monitor_page_title_query.ts deleted file mode 100644 index 3b59ef80183f7..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/queries/monitor_page_title_query.ts +++ /dev/null @@ -1,20 +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 gql from 'graphql-tag'; - -export const monitorPageTitleQueryString = ` -query MonitorPageTitle($monitorId: String!) { - monitorPageTitle: getMonitorPageTitle(monitorId: $monitorId) { - id - url - name - } -}`; - -export const monitorPageTitleQuery = gql` - ${monitorPageTitleQueryString} -`; diff --git a/x-pack/legacy/plugins/uptime/public/queries/monitor_status_bar_query.ts b/x-pack/legacy/plugins/uptime/public/queries/monitor_status_bar_query.ts deleted file mode 100644 index 1f0eed6bb366a..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/queries/monitor_status_bar_query.ts +++ /dev/null @@ -1,41 +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 gql from 'graphql-tag'; - -export const monitorStatusBarQueryString = ` -query MonitorStatus($dateRangeStart: String!, $dateRangeEnd: String!, $monitorId: String, $location: String) { - monitorStatus: getLatestMonitors( - dateRangeStart: $dateRangeStart - dateRangeEnd: $dateRangeEnd - monitorId: $monitorId - location: $location - ) { - timestamp - monitor { - status - duration { - us - } - } - observer { - geo { - name - } - } - tls { - certificate_not_valid_after - } - url { - full - } - } -} -`; - -export const monitorStatusBarQuery = gql` - ${monitorStatusBarQueryString} -`; diff --git a/x-pack/legacy/plugins/uptime/public/routes.tsx b/x-pack/legacy/plugins/uptime/public/routes.tsx new file mode 100644 index 0000000000000..08d752f5b32ab --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/routes.tsx @@ -0,0 +1,32 @@ +/* + * 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 } from 'react'; +import { Route, Switch } from 'react-router-dom'; +import { MonitorPage, OverviewPage, NotFoundPage } from './pages'; +import { AutocompleteProviderRegister } from '../../../../../src/plugins/data/public'; +import { UMUpdateBreadcrumbs } from './lib/lib'; + +export const MONITOR_ROUTE = '/monitor/:monitorId/:location?'; +export const OVERVIEW_ROUTE = '/'; + +interface RouterProps { + autocomplete: Pick; + basePath: string; + setBreadcrumbs: UMUpdateBreadcrumbs; +} + +export const PageRouter: FC = ({ autocomplete, basePath, setBreadcrumbs }) => ( + + + + + + + + + +); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/index.ts b/x-pack/legacy/plugins/uptime/public/state/actions/index.ts index 6b896b07bb066..3174c0023ed92 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/index.ts @@ -6,3 +6,4 @@ export * from './snapshot'; export * from './ui'; +export * from './monitor_status'; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts new file mode 100644 index 0000000000000..db103f6cb780e --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.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 { createAction } from 'redux-actions'; +import { QueryParams } from './types'; + +export const getSelectedMonitor = createAction<{ monitorId: string }>('GET_SELECTED_MONITOR'); +export const getSelectedMonitorSuccess = createAction('GET_SELECTED_MONITOR_SUCCESS'); +export const getSelectedMonitorFail = createAction('GET_SELECTED_MONITOR_FAIL'); + +export const getMonitorStatus = createAction('GET_MONITOR_STATUS'); +export const getMonitorStatusSuccess = createAction('GET_MONITOR_STATUS_SUCCESS'); +export const getMonitorStatusFail = createAction('GET_MONITOR_STATUS_FAIL'); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/types.ts b/x-pack/legacy/plugins/uptime/public/state/actions/types.ts index 8419aaadc74bd..dba70ed839ac5 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/types.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/types.ts @@ -5,10 +5,12 @@ */ export interface QueryParams { + monitorId: string; dateStart: string; dateEnd: string; filters?: string; statusFilter?: string; + location?: string; } export interface MonitorDetailsActionPayload { diff --git a/x-pack/legacy/plugins/uptime/public/state/api/index.ts b/x-pack/legacy/plugins/uptime/public/state/api/index.ts index a4429868494f1..0bcd29cf3fbe9 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/index.ts @@ -6,3 +6,4 @@ export * from './monitor'; export * from './snapshot'; +export * from './monitor_status'; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts new file mode 100644 index 0000000000000..936e864b75619 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getApiPath } from '../../lib/helper'; +import { QueryParams } from '../actions/types'; +import { Ping } from '../../../common/graphql/types'; + +export interface APIParams { + basePath: string; + monitorId: string; +} + +export const fetchSelectedMonitor = async ({ basePath, monitorId }: APIParams): Promise => { + const url = getApiPath(`/api/uptime/monitor/selected`, basePath); + const params = { + monitorId, + }; + const urlParams = new URLSearchParams(params).toString(); + const response = await fetch(`${url}?${urlParams}`); + if (!response.ok) { + throw new Error(response.statusText); + } + const responseData = await response.json(); + return responseData; +}; + +export const fetchMonitorStatus = async ({ + basePath, + monitorId, + dateStart, + dateEnd, +}: QueryParams & APIParams): Promise => { + const url = getApiPath(`/api/uptime/monitor/status`, basePath); + const params = { + monitorId, + dateStart, + dateEnd, + }; + const urlParams = new URLSearchParams(params).toString(); + const response = await fetch(`${url}?${urlParams}`); + if (!response.ok) { + throw new Error(response.statusText); + } + const responseData = await response.json(); + return responseData; +}; diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/index.ts b/x-pack/legacy/plugins/uptime/public/state/effects/index.ts index 4eb027d642974..936b38a4d5345 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/index.ts @@ -7,8 +7,10 @@ import { fork } from 'redux-saga/effects'; import { fetchMonitorDetailsEffect } from './monitor'; import { fetchSnapshotCountSaga } from './snapshot'; +import { fetchMonitorStatusEffect } from './monitor_status'; export function* rootEffect() { yield fork(fetchMonitorDetailsEffect); yield fork(fetchSnapshotCountSaga); + yield fork(fetchMonitorStatusEffect); } diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/effects/monitor_status.ts new file mode 100644 index 0000000000000..cab32092a14cd --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/effects/monitor_status.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { call, put, takeLatest, select } from 'redux-saga/effects'; +import { Action } from 'redux-actions'; +import { + getSelectedMonitor, + getSelectedMonitorSuccess, + getSelectedMonitorFail, + getMonitorStatus, + getMonitorStatusSuccess, + getMonitorStatusFail, +} from '../actions/monitor_status'; +import { fetchSelectedMonitor, fetchMonitorStatus } from '../api'; +import { getBasePath } from '../selectors'; + +function* selectedMonitorEffect(action: Action) { + const { monitorId } = action.payload; + try { + const basePath = yield select(getBasePath); + const response = yield call(fetchSelectedMonitor, { + monitorId, + basePath, + }); + yield put({ type: getSelectedMonitorSuccess, payload: response }); + } catch (error) { + yield put({ type: getSelectedMonitorFail, payload: error.message }); + } +} + +function* monitorStatusEffect(action: Action) { + const { monitorId, dateStart, dateEnd } = action.payload; + try { + const basePath = yield select(getBasePath); + const response = yield call(fetchMonitorStatus, { + monitorId, + basePath, + dateStart, + dateEnd, + }); + yield put({ type: getMonitorStatusSuccess, payload: response }); + } catch (error) { + yield put({ type: getMonitorStatusFail, payload: error.message }); + } +} + +export function* fetchMonitorStatusEffect() { + yield takeLatest(getMonitorStatus, monitorStatusEffect); + yield takeLatest(getSelectedMonitor, selectedMonitorEffect); +} diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts index b588feaf8a75a..7b4000adc2d12 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts @@ -8,10 +8,11 @@ import { combineReducers } from 'redux'; import { monitorReducer } from './monitor'; import { snapshotReducer } from './snapshot'; import { uiReducer } from './ui'; +import { monitorStatusReducer } from './monitor_status'; export const rootReducer = combineReducers({ monitor: monitorReducer, snapshot: snapshotReducer, - // @ts-ignore for now TODO: refactor to use redux-action ui: uiReducer, + monitorStatus: monitorStatusReducer, }); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts index 220ab0b205462..aac8a90598d0c 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts @@ -19,10 +19,10 @@ import { MonitorLocations } from '../../../common/runtime_types'; type MonitorLocationsList = Map; export interface MonitorState { - monitorDetailsList: MonitorDetailsState[]; - monitorLocationsList: MonitorLocationsList; loading: boolean; errors: any[]; + monitorDetailsList: MonitorDetailsState[]; + monitorLocationsList: MonitorLocationsList; } const initialState: MonitorState = { diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts new file mode 100644 index 0000000000000..2688a0946dd61 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.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 { handleActions, Action } from 'redux-actions'; +import { + getSelectedMonitor, + getSelectedMonitorSuccess, + getSelectedMonitorFail, + getMonitorStatus, + getMonitorStatusSuccess, + getMonitorStatusFail, +} from '../actions'; +import { Ping } from '../../../common/graphql/types'; +import { QueryParams } from '../actions/types'; + +export interface MonitorStatusState { + status: Ping | null; + monitor: Ping | null; + loading: boolean; +} + +const initialState: MonitorStatusState = { + status: null, + monitor: null, + loading: false, +}; + +type MonitorStatusPayload = QueryParams & Ping; + +export const monitorStatusReducer = handleActions( + { + [String(getSelectedMonitor)]: (state, action: Action) => ({ + ...state, + loading: true, + }), + + [String(getSelectedMonitorSuccess)]: (state, action: Action) => ({ + ...state, + loading: false, + monitor: { ...action.payload } as Ping, + }), + + [String(getSelectedMonitorFail)]: (state, action: Action) => ({ + ...state, + loading: false, + }), + + [String(getMonitorStatus)]: (state, action: Action) => ({ + ...state, + loading: true, + }), + + [String(getMonitorStatusSuccess)]: (state, action: Action) => ({ + ...state, + loading: false, + status: { ...action.payload } as Ping, + }), + + [String(getMonitorStatusFail)]: (state, action: Action) => ({ + ...state, + loading: false, + }), + }, + initialState +); diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts index 2378770601594..df8e640bcbab5 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -24,10 +24,11 @@ describe('state selectors', () => { errors: [], loading: false, }, - ui: { - basePath: 'yyz', - integrationsPopoverOpen: null, - lastRefresh: 125, + ui: { basePath: 'yyz', integrationsPopoverOpen: null, lastRefresh: 125 }, + monitorStatus: { + status: null, + monitor: null, + loading: false, }, }; diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts index 08d1e8c8ca36d..337e99f6ede16 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts @@ -17,6 +17,14 @@ export const getMonitorDetails = (state: AppState, summary: any) => { return state.monitor.monitorDetailsList[summary.monitor_id]; }; -export const getMonitorLocations = (state: AppState, monitorId: string) => { +export const selectMonitorLocations = (state: AppState, monitorId: string) => { return state.monitor.monitorLocationsList?.get(monitorId); }; + +export const selectSelectedMonitor = (state: AppState) => { + return state.monitorStatus.monitor; +}; + +export const selectMonitorStatus = (state: AppState) => { + return state.monitorStatus.status; +}; diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx index cecbfe375f5fe..25ff0e7177016 100644 --- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx +++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx @@ -5,25 +5,25 @@ */ import DateMath from '@elastic/datemath'; -import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiPage } from '@elastic/eui'; import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; import { ApolloProvider } from 'react-apollo'; import { Provider as ReduxProvider } from 'react-redux'; -import { BrowserRouter as Router, Route, RouteComponentProps, Switch } from 'react-router-dom'; +import { BrowserRouter as Router, Route, RouteComponentProps } from 'react-router-dom'; import { I18nStart, ChromeBreadcrumb, LegacyCoreStart } from 'src/core/public'; import { PluginsStart } from 'ui/new_platform/new_platform'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { UMGraphQLClient, UMUpdateBreadcrumbs, UMUpdateBadge } from './lib/lib'; -import { MonitorPage, OverviewPage, NotFoundPage } from './pages'; import { UptimeRefreshContext, UptimeSettingsContext, UMSettingsContextValues } from './contexts'; -import { UptimeDatePicker, CommonlyUsedRange } from './components/functional/uptime_date_picker'; +import { CommonlyUsedRange } from './components/functional/uptime_date_picker'; import { useUrlParams } from './hooks'; import { getTitle } from './lib/helper/get_title'; import { store } from './state'; import { setBasePath, triggerAppRefresh } from './state/actions'; +import { PageRouter } from './routes'; export interface UptimeAppColors { danger: string; @@ -93,7 +93,6 @@ const Application = (props: UptimeAppProps) => { }; } const [lastRefresh, setLastRefresh] = useState(Date.now()); - const [headingText, setHeadingText] = useState(undefined); useEffect(() => { renderGlobalHelpControls(); @@ -146,7 +145,7 @@ const Application = (props: UptimeAppProps) => { isInfraAvailable, isLogsAvailable, refreshApp, - setHeadingText, + commonlyUsedRanges, }; }; @@ -166,49 +165,11 @@ const Application = (props: UptimeAppProps) => {
- - - -

{headingText}

-
-
- - - -
- - - ( - - )} - /> - ( - - )} - /> - - +
diff --git a/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts b/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts index 8b685d8e08a2b..d53bf6b701500 100644 --- a/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts +++ b/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts @@ -9,12 +9,8 @@ import { UMResolver } from '../../../common/graphql/resolver_types'; import { FilterBar, GetFilterBarQueryArgs, - GetLatestMonitorsQueryArgs, GetMonitorChartsDataQueryArgs, - GetMonitorPageTitleQueryArgs, MonitorChart, - MonitorPageTitle, - Ping, GetSnapshotHistogramQueryArgs, } from '../../../common/graphql/types'; import { UMServerLibs } from '../../lib/lib'; @@ -23,13 +19,6 @@ import { HistogramResult } from '../../../common/domain_types'; export type UMMonitorsResolver = UMResolver, any, UMGqlRange, UMContext>; -export type UMLatestMonitorsResolver = UMResolver< - Ping[] | Promise, - any, - GetLatestMonitorsQueryArgs, - UMContext ->; - export type UMGetMonitorChartsResolver = UMResolver< any | Promise, any, @@ -44,13 +33,6 @@ export type UMGetFilterBarResolver = UMResolver< UMContext >; -export type UMGetMontiorPageTitleResolver = UMResolver< - MonitorPageTitle | Promise | null, - any, - GetMonitorPageTitleQueryArgs, - UMContext ->; - export type UMGetSnapshotHistogram = UMResolver< HistogramResult | Promise, any, @@ -64,9 +46,7 @@ export const createMonitorsResolvers: CreateUMGraphQLResolvers = ( Query: { getSnapshotHistogram: UMGetSnapshotHistogram; getMonitorChartsData: UMGetMonitorChartsResolver; - getLatestMonitors: UMLatestMonitorsResolver; getFilterBar: UMGetFilterBarResolver; - getMonitorPageTitle: UMGetMontiorPageTitleResolver; }; } => ({ Query: { @@ -97,19 +77,6 @@ export const createMonitorsResolvers: CreateUMGraphQLResolvers = ( location, }); }, - async getLatestMonitors( - _resolver, - { dateRangeStart, dateRangeEnd, monitorId, location }, - { APICaller } - ): Promise { - return await libs.pings.getLatestMonitorDocs({ - callES: APICaller, - dateRangeStart, - dateRangeEnd, - monitorId, - location, - }); - }, async getFilterBar( _resolver, { dateRangeStart, dateRangeEnd }, @@ -121,12 +88,5 @@ export const createMonitorsResolvers: CreateUMGraphQLResolvers = ( dateRangeEnd, }); }, - async getMonitorPageTitle( - _resolver: any, - { monitorId }, - { APICaller } - ): Promise { - return await libs.monitors.getMonitorPageTitle({ callES: APICaller, monitorId }); - }, }, }); diff --git a/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts b/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts index f9b14c63e70bb..d49681f1dcd0d 100644 --- a/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts +++ b/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts @@ -114,12 +114,6 @@ export const monitorsSchema = gql` interval: UnsignedInteger! } - type MonitorPageTitle { - id: String! - url: String - name: String - } - extend type Query { getMonitors( dateRangeStart: String! @@ -156,7 +150,5 @@ export const monitorsSchema = gql` ): [Ping!]! getFilterBar(dateRangeStart: String!, dateRangeEnd: String!): FilterBar - - getMonitorPageTitle(monitorId: String!): MonitorPageTitle } `; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts index 99d346e5d666e..f844d6ecc1c4a 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MonitorChart, MonitorPageTitle } from '../../../../common/graphql/types'; +import { MonitorChart } from '../../../../common/graphql/types'; import { UMElasticsearchQueryFn } from '../framework'; import { MonitorDetails, MonitorLocations } from '../../../../common/runtime_types'; @@ -31,11 +31,6 @@ export interface GetMonitorDetailsParams { dateEnd: string; } -export interface GetMonitorPageTitleParams { - /** @member monitorId the ID to query */ - monitorId: string; -} - /** * Fetch data for the monitor page title. */ @@ -57,7 +52,6 @@ export interface UMMonitorsAdapter { /** * Fetch data for the monitor page title. */ - getMonitorPageTitle: UMElasticsearchQueryFn<{ monitorId: string }, MonitorPageTitle | null>; getMonitorDetails: UMElasticsearchQueryFn; getMonitorLocations: UMElasticsearchQueryFn; } diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts index 5f4c2e45d9759..a452f2364540a 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts @@ -6,7 +6,7 @@ import { get } from 'lodash'; import { INDEX_NAMES } from '../../../../common/constants'; -import { MonitorChart, Ping, LocationDurationLine } from '../../../../common/graphql/types'; +import { MonitorChart, LocationDurationLine } from '../../../../common/graphql/types'; import { getHistogramIntervalFormatted } from '../../helper'; import { MonitorError, MonitorLocation } from '../../../../common/runtime_types'; import { UMMonitorsAdapter } from './adapter_types'; @@ -195,42 +195,6 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { }, {}); }, - getMonitorPageTitle: async ({ callES, monitorId }) => { - const params = { - index: INDEX_NAMES.HEARTBEAT, - body: { - query: { - bool: { - filter: { - term: { - 'monitor.id': monitorId, - }, - }, - }, - }, - sort: [ - { - '@timestamp': { - order: 'desc', - }, - }, - ], - size: 1, - }, - }; - - const result = await callES('search', params); - const pageTitle: Ping | null = get(result, 'hits.hits[0]._source', null); - if (pageTitle === null) { - return null; - } - return { - id: get(pageTitle, 'monitor.id', null) || monitorId, - url: get(pageTitle, 'url.full', null), - name: get(pageTitle, 'monitor.name', null), - }; - }, - getMonitorDetails: async ({ callES, monitorId, dateStart, dateEnd }) => { const queryFilters: any = [ { @@ -289,11 +253,6 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { }; }, - /** - * Fetch data for the monitor page title. - * @param request Kibana server request - * - */ getMonitorLocations: async ({ callES, monitorId, dateStart, dateEnd }) => { const params = { index: INDEX_NAMES.HEARTBEAT, diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/__tests__/elasticsearch_pings_adapter.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/__tests__/elasticsearch_pings_adapter.test.ts index bd1c182e938a3..e1e39ac9b2637 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/__tests__/elasticsearch_pings_adapter.test.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/__tests__/elasticsearch_pings_adapter.test.ts @@ -411,7 +411,7 @@ describe('ElasticsearchPingsAdapter class', () => { }); }); - describe('getLatestMonitorDocs', () => { + describe('getLatestMonitorStatus', () => { let expectedGetLatestSearchParams: any; beforeEach(() => { expectedGetLatestSearchParams = { @@ -429,7 +429,7 @@ describe('ElasticsearchPingsAdapter class', () => { }, }, { - term: { 'monitor.id': 'testmonitor' }, + term: { 'monitor.id': 'testMonitor' }, }, ], }, @@ -467,7 +467,7 @@ describe('ElasticsearchPingsAdapter class', () => { _source: { '@timestamp': 123456, monitor: { - id: 'testmonitor', + id: 'testMonitor', }, }, }, @@ -483,17 +483,16 @@ describe('ElasticsearchPingsAdapter class', () => { it('returns data in expected shape', async () => { const mockEsClient = jest.fn(async (_request: any, _params: any) => mockEsSearchResult); - const result = await adapter.getLatestMonitorDocs({ + const result = await adapter.getLatestMonitorStatus({ callES: mockEsClient, - dateRangeStart: 'now-1h', - dateRangeEnd: 'now', - monitorId: 'testmonitor', + dateStart: 'now-1h', + dateEnd: 'now', + monitorId: 'testMonitor', }); - expect(result).toHaveLength(1); - expect(result[0].timestamp).toBe(123456); - expect(result[0].monitor).not.toBeFalsy(); + expect(result.timestamp).toBe(123456); + expect(result.monitor).not.toBeFalsy(); // @ts-ignore monitor will be defined - expect(result[0].monitor.id).toBe('testmonitor'); + expect(result.monitor.id).toBe('testMonitor'); expect(mockEsClient).toHaveBeenCalledWith('search', expectedGetLatestSearchParams); }); }); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/adapter_types.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/adapter_types.ts index 81df1c7c0f631..8b2a49c0c9ffe 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/adapter_types.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/adapter_types.ts @@ -33,16 +33,13 @@ export interface GetAllParams { export interface GetLatestMonitorDocsParams { /** @member dateRangeStart timestamp bounds */ - dateRangeStart: string; + dateStart?: string; /** @member dateRangeEnd timestamp bounds */ - dateRangeEnd: string; + dateEnd?: string; /** @member monitorId optional limit to monitorId */ monitorId?: string | null; - - /** @member location optional location value for use in filtering*/ - location?: string | null; } export interface GetPingHistogramParams { @@ -64,7 +61,10 @@ export interface GetPingHistogramParams { export interface UMPingsAdapter { getAll: UMElasticsearchQueryFn; - getLatestMonitorDocs: UMElasticsearchQueryFn; + // Get the monitor meta info regardless of timestamp + getMonitor: UMElasticsearchQueryFn; + + getLatestMonitorStatus: UMElasticsearchQueryFn; getPingHistogram: UMElasticsearchQueryFn; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/elasticsearch_pings_adapter.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/elasticsearch_pings_adapter.ts index d05cb5758ca36..adabffcb1ea4a 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/elasticsearch_pings_adapter.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/elasticsearch_pings_adapter.ts @@ -88,7 +88,10 @@ export const elasticsearchPingsAdapter: UMPingsAdapter = { return results; }, - getLatestMonitorDocs: async ({ callES, dateRangeStart, dateRangeEnd, monitorId, location }) => { + // Get The monitor latest state sorted by timestamp with date range + getLatestMonitorStatus: async ({ callES, dateStart, dateEnd, monitorId }) => { + // TODO: Write tests for this function + const params = { index: INDEX_NAMES.HEARTBEAT, body: { @@ -98,13 +101,12 @@ export const elasticsearchPingsAdapter: UMPingsAdapter = { { range: { '@timestamp': { - gte: dateRangeStart, - lte: dateRangeEnd, + gte: dateStart, + lte: dateEnd, }, }, }, ...(monitorId ? [{ term: { 'monitor.id': monitorId } }] : []), - ...(location ? [{ term: { 'observer.geo.name': location } }] : []), ], }, }, @@ -131,21 +133,45 @@ export const elasticsearchPingsAdapter: UMPingsAdapter = { }; const result = await callES('search', params); - const buckets: any[] = get(result, 'aggregations.by_id.buckets', []); + const ping: any = result.aggregations.by_id.buckets?.[0]?.latest.hits?.hits?.[0] ?? {}; + + return { + ...ping?._source, + timestamp: ping?._source?.['@timestamp'], + }; + }, - return buckets.map( - ({ - latest: { - hits: { hits }, + // Get the monitor meta info regardless of timestamp + getMonitor: async ({ callES, monitorId }) => { + const params = { + index: INDEX_NAMES.HEARTBEAT, + body: { + size: 1, + _source: ['url', 'monitor', 'observer'], + query: { + bool: { + filter: [ + { + term: { + 'monitor.id': monitorId, + }, + }, + ], + }, }, - }) => { - const timestamp = hits[0]._source[`@timestamp`]; - return { - ...hits[0]._source, - timestamp, - }; - } - ); + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + }, + }; + + const result = await callES('search', params); + + return result.hits.hits[0]?._source; }, getPingHistogram: async ({ diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/index.ts b/x-pack/legacy/plugins/uptime/server/rest_api/index.ts index 4ab225076eff2..4bc6fe699a21a 100644 --- a/x-pack/legacy/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/legacy/plugins/uptime/server/rest_api/index.ts @@ -9,7 +9,12 @@ import { createGetIndexPatternRoute } from './index_pattern'; import { createLogMonitorPageRoute, createLogOverviewPageRoute } from './telemetry'; import { createGetSnapshotCount } from './snapshot'; import { UMRestApiRouteFactory } from './types'; -import { createGetMonitorDetailsRoute, createGetMonitorLocationsRoute } from './monitors'; +import { + createGetMonitorRoute, + createGetMonitorDetailsRoute, + createGetMonitorLocationsRoute, + createGetStatusBarRoute, +} from './monitors'; export * from './types'; export { createRouteWithAuth } from './create_route_with_auth'; @@ -17,8 +22,10 @@ export { uptimeRouteWrapper } from './uptime_route_wrapper'; export const restApiRoutes: UMRestApiRouteFactory[] = [ createGetAllRoute, createGetIndexPatternRoute, + createGetMonitorRoute, createGetMonitorDetailsRoute, createGetMonitorLocationsRoute, + createGetStatusBarRoute, createGetSnapshotCount, createLogMonitorPageRoute, createLogOverviewPageRoute, diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/monitors/index.ts b/x-pack/legacy/plugins/uptime/server/rest_api/monitors/index.ts index 2279233d49a09..7f1f10081dc4e 100644 --- a/x-pack/legacy/plugins/uptime/server/rest_api/monitors/index.ts +++ b/x-pack/legacy/plugins/uptime/server/rest_api/monitors/index.ts @@ -6,3 +6,4 @@ export { createGetMonitorDetailsRoute } from './monitors_details'; export { createGetMonitorLocationsRoute } from './monitor_locations'; +export { createGetMonitorRoute, createGetStatusBarRoute } from './status'; diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/monitors/status.ts b/x-pack/legacy/plugins/uptime/server/rest_api/monitors/status.ts new file mode 100644 index 0000000000000..8b1bc04b45110 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/rest_api/monitors/status.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { UMServerLibs } from '../../lib/lib'; +import { UMRestApiRouteFactory } from '../types'; + +export const createGetMonitorRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/monitor/selected', + validate: { + query: schema.object({ + monitorId: schema.string(), + }), + }, + options: { + tags: ['access:uptime'], + }, + handler: async ({ callES }, _context, request, response): Promise => { + const { monitorId } = request.query; + + return response.ok({ + body: { + ...(await libs.pings.getMonitor({ callES, monitorId })), + }, + }); + }, +}); + +export const createGetStatusBarRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/monitor/status', + validate: { + query: schema.object({ + monitorId: schema.string(), + dateStart: schema.string(), + dateEnd: schema.string(), + }), + }, + options: { + tags: ['access:uptime'], + }, + handler: async ({ callES }, _context, request, response): Promise => { + const { monitorId, dateStart, dateEnd } = request.query; + const result = await libs.pings.getLatestMonitorStatus({ + callES, + monitorId, + dateStart, + dateEnd, + }); + return response.ok({ + body: { + ...result, + }, + }); + }, +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 916360a46e5a1..9712c7c347303 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11741,7 +11741,6 @@ "xpack.uptime.emptyState.loadingMessage": "読み込み中…", "xpack.uptime.emptyState.noDataTitle": "利用可能なアップタイムデータがありません", "xpack.uptime.emptyStateError.title": "エラー", - "xpack.uptime.emptyStatusBar.defaultMessage": "監視 ID {monitorId} のデータが見つかりません", "xpack.uptime.errorMessage": "エラー: {message}", "xpack.uptime.featureCatalogueDescription": "エンドポイントヘルスチェックとアップタイム監視を行います。", "xpack.uptime.featureRegistry.uptimeFeatureName": "アップタイム", @@ -11781,7 +11780,6 @@ "xpack.uptime.monitorList.statusColumn.upLabel": "アップ", "xpack.uptime.monitorList.statusColumnLabel": "ステータス", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "ミリ秒単位の監視時間", - "xpack.uptime.monitorStatusBar.healthStatus.durationInMillisecondsMessage": "{duration}ms", "xpack.uptime.monitorStatusBar.healthStatusMessage.downLabel": "ダウン", "xpack.uptime.monitorStatusBar.healthStatusMessage.upLabel": "アップ", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "監視ステータス", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f2806b0b33c29..21ec06c6a191e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11830,7 +11830,6 @@ "xpack.uptime.emptyState.loadingMessage": "正在加载……", "xpack.uptime.emptyState.noDataTitle": "没有可用的运行时间数据", "xpack.uptime.emptyStateError.title": "错误", - "xpack.uptime.emptyStatusBar.defaultMessage": "未找到监测 ID {monitorId} 的数据", "xpack.uptime.errorMessage": "错误:{message}", "xpack.uptime.featureCatalogueDescription": "执行终端节点运行状况检查和运行时间监测。", "xpack.uptime.featureRegistry.uptimeFeatureName": "运行时间", @@ -11870,7 +11869,6 @@ "xpack.uptime.monitorList.statusColumn.upLabel": "运行", "xpack.uptime.monitorList.statusColumnLabel": "状态", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "监测持续时间(毫秒)", - "xpack.uptime.monitorStatusBar.healthStatus.durationInMillisecondsMessage": "{duration}ms", "xpack.uptime.monitorStatusBar.healthStatusMessage.downLabel": "关闭", "xpack.uptime.monitorStatusBar.healthStatusMessage.upLabel": "运行", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "检测状态", diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_page_title.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_page_title.json deleted file mode 100644 index c0dac6c3711bc..0000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_page_title.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "monitorPageTitle": { - "id": "0002-up", - "url": "http://localhost:5678/pattern?r=200x1", - "name": "" - } -} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_status_by_id.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_status_by_id.json deleted file mode 100644 index 8899803365595..0000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_status_by_id.json +++ /dev/null @@ -1,20 +0,0 @@ -[ - { - "timestamp": "2019-09-11T03:40:34.371Z", - "monitor": { - "status": "up", - "duration": { - "us": 24627 - } - }, - "observer": { - "geo": { - "name": "mpls" - } - }, - "tls": null, - "url": { - "full": "http://localhost:5678/pattern?r=200x1" - } - } -] \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/helpers/expect_fixture_eql.ts b/x-pack/test/api_integration/apis/uptime/graphql/helpers/expect_fixture_eql.ts index 362803d2b8550..45cc9011773a9 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/helpers/expect_fixture_eql.ts +++ b/x-pack/test/api_integration/apis/uptime/graphql/helpers/expect_fixture_eql.ts @@ -10,6 +10,7 @@ import { join } from 'path'; import { cloneDeep } from 'lodash'; const fixturesDir = join(__dirname, '..', 'fixtures'); +const restFixturesDir = join(__dirname, '../../rest/', 'fixtures'); const excludeFieldsFrom = (from: any, excluder?: (d: any) => any): any => { const clone = cloneDeep(from); @@ -20,7 +21,11 @@ const excludeFieldsFrom = (from: any, excluder?: (d: any) => any): any => { }; export const expectFixtureEql = (data: T, fixtureName: string, excluder?: (d: T) => void) => { - const fixturePath = join(fixturesDir, `${fixtureName}.json`); + let fixturePath = join(fixturesDir, `${fixtureName}.json`); + if (!fs.existsSync(fixturePath)) { + fixturePath = join(restFixturesDir, `${fixtureName}.json`); + } + const dataExcluded = excludeFieldsFrom(data, excluder); expect(dataExcluded).not.to.be(undefined); if (process.env.UPDATE_UPTIME_FIXTURES) { diff --git a/x-pack/test/api_integration/apis/uptime/graphql/index.js b/x-pack/test/api_integration/apis/uptime/graphql/index.js index 3a696244a5eba..5c93be83ab7d9 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/index.js +++ b/x-pack/test/api_integration/apis/uptime/graphql/index.js @@ -13,9 +13,7 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./doc_count')); loadTestFile(require.resolve('./filter_bar')); loadTestFile(require.resolve('./monitor_charts')); - loadTestFile(require.resolve('./monitor_page_title')); loadTestFile(require.resolve('./monitor_states')); - loadTestFile(require.resolve('./monitor_status_bar')); loadTestFile(require.resolve('./ping_list')); loadTestFile(require.resolve('./snapshot_histogram')); }); diff --git a/x-pack/test/api_integration/apis/uptime/graphql/monitor_page_title.ts b/x-pack/test/api_integration/apis/uptime/graphql/monitor_page_title.ts deleted file mode 100644 index 56d22610ffe49..0000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/monitor_page_title.ts +++ /dev/null @@ -1,37 +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 { monitorPageTitleQueryString } from '../../../../../legacy/plugins/uptime/public/queries/monitor_page_title_query'; -import { FtrProviderContext } from '../../../ftr_provider_context'; -import { expectFixtureEql } from './helpers/expect_fixture_eql'; - -export default function({ getService }: FtrProviderContext) { - describe('monitor_page_title', () => { - before('load heartbeat data', () => getService('esArchiver').load('uptime/full_heartbeat')); - after('unload heartbeat index', () => getService('esArchiver').unload('uptime/full_heartbeat')); - - const supertest = getService('supertest'); - - it('will fetch a title for a given monitorId', async () => { - const getMonitorTitleQuery = { - operationName: 'MonitorPageTitle', - query: monitorPageTitleQueryString, - variables: { - monitorId: '0002-up', - }, - }; - - const { - body: { data }, - } = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getMonitorTitleQuery }); - - expectFixtureEql(data, 'monitor_page_title'); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/uptime/graphql/monitor_status_bar.js b/x-pack/test/api_integration/apis/uptime/graphql/monitor_status_bar.js deleted file mode 100644 index 5d03c90bb2032..0000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/monitor_status_bar.js +++ /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 { monitorStatusBarQueryString } from '../../../../../legacy/plugins/uptime/public/queries'; -import { expectFixtureEql } from './helpers/expect_fixture_eql'; - -export default function({ getService }) { - describe('monitorStatusBar query', () => { - before('load heartbeat data', () => getService('esArchiver').load('uptime/full_heartbeat')); - after('unload heartbeat index', () => getService('esArchiver').unload('uptime/full_heartbeat')); - - const supertest = getService('supertest'); - - it('returns the status for all monitors with no ID filtering', async () => { - const getMonitorStatusBarQuery = { - operationName: 'MonitorStatus', - query: monitorStatusBarQueryString, - variables: { - dateRangeStart: '2019-01-28T17:40:08.078Z', - dateRangeEnd: '2025-01-28T19:00:16.078Z', - }, - }; - const { - body: { - data: { monitorStatus: responseData }, - }, - } = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getMonitorStatusBarQuery }); - - expectFixtureEql(responseData, 'monitor_status_all', res => - res.forEach(i => delete i.millisFromNow) - ); - }); - - it('returns the status for only the given monitor', async () => { - const getMonitorStatusBarQuery = { - operationName: 'MonitorStatus', - query: monitorStatusBarQueryString, - variables: { - dateRangeStart: '2019-01-28T17:40:08.078Z', - dateRangeEnd: '2025-01-28T19:00:16.078Z', - monitorId: '0002-up', - }, - }; - const res = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getMonitorStatusBarQuery }); - - expectFixtureEql(res.body.data.monitorStatus, 'monitor_status_by_id'); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json new file mode 100644 index 0000000000000..1702cb2c21007 --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json @@ -0,0 +1,89 @@ +{ + "@timestamp": "2019-09-11T03:40:34.371Z", + "agent": { + "ephemeral_id": "412a92a8-2142-4b1a-a7a2-1afd32e12f85", + "hostname": "avc-x1x", + "id": "04e1d082-65bc-4929-8d65-d0768a2621c4", + "type": "heartbeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.1.0" + }, + "event": { + "dataset": "uptime" + }, + "host": { + "name": "avc-x1x" + }, + "http": { + "response": { + "body": { + "bytes": 3, + "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf" + }, + "status_code": 200 + }, + "rtt": { + "content": { + "us": 57 + }, + "response_header": { + "us": 262 + }, + "total": { + "us": 20331 + }, + "validate": { + "us": 319 + }, + "write_request": { + "us": 82 + } + } + }, + "monitor": { + "check_group": "d76f0762-d445-11e9-88e3-3e80641b9c71", + "duration": { + "us": 24627 + }, + "id": "0002-up", + "ip": "127.0.0.1", + "name": "", + "status": "up", + "type": "http" + }, + "observer": { + "geo": { + "location": "37.926868, -78.024902", + "name": "mpls" + }, + "hostname": "avc-x1x" + }, + "resolve": { + "ip": "127.0.0.1", + "rtt": { + "us": 4218 + } + }, + "summary": { + "down": 0, + "up": 1 + }, + "tcp": { + "rtt": { + "connect": { + "us": 103 + } + } + }, + "timestamp": "2019-09-11T03:40:34.371Z", + "url": { + "domain": "localhost", + "full": "http://localhost:5678/pattern?r=200x1", + "path": "/pattern", + "port": 5678, + "query": "r=200x1", + "scheme": "http" + } + } diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/selected_monitor.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/selected_monitor.json new file mode 100644 index 0000000000000..d8367ea67052f --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/selected_monitor.json @@ -0,0 +1,28 @@ +{ + "monitor": { + "check_group": "d76f0762-d445-11e9-88e3-3e80641b9c71", + "duration": { + "us": 24627 + }, + "id": "0002-up", + "ip": "127.0.0.1", + "name": "", + "status": "up", + "type": "http" + }, + "observer": { + "geo": { + "location": "37.926868, -78.024902", + "name": "mpls" + }, + "hostname": "avc-x1x" + }, + "url": { + "domain": "localhost", + "full": "http://localhost:5678/pattern?r=200x1", + "path": "/pattern", + "port": 5678, + "query": "r=200x1", + "scheme": "http" + } +} diff --git a/x-pack/test/api_integration/apis/uptime/rest/index.ts b/x-pack/test/api_integration/apis/uptime/rest/index.ts index 68377e0820e05..ef80c1d8728fe 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/index.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/index.ts @@ -12,5 +12,7 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { before('load heartbeat data', () => esArchiver.load('uptime/blank')); after('unload', () => esArchiver.unload('uptime/blank')); loadTestFile(require.resolve('./snapshot')); + loadTestFile(require.resolve('./monitor_latest_status')); + loadTestFile(require.resolve('./selected_monitor')); }); } diff --git a/x-pack/test/api_integration/apis/uptime/rest/monitor_latest_status.ts b/x-pack/test/api_integration/apis/uptime/rest/monitor_latest_status.ts new file mode 100644 index 0000000000000..749b304c87ee3 --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/monitor_latest_status.ts @@ -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 { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + describe('get monitor latest status API', () => { + const dateStart = '2018-01-28T17:40:08.078Z'; + const dateEnd = '2025-01-28T19:00:16.078Z'; + const monitorId = '0002-up'; + + const supertest = getService('supertest'); + + it('returns the status for only the given monitor', async () => { + const apiResponse = await supertest.get( + `/api/uptime/monitor/status?monitorId=${monitorId}&dateStart=${dateStart}&dateEnd=${dateEnd}` + ); + expectFixtureEql(apiResponse.body, 'monitor_latest_status'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/uptime/rest/selected_monitor.ts b/x-pack/test/api_integration/apis/uptime/rest/selected_monitor.ts new file mode 100644 index 0000000000000..ed034f58a5f59 --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/selected_monitor.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + describe('get selected monitor by ID', () => { + const monitorId = '0002-up'; + + const supertest = getService('supertest'); + + it('returns the monitor for give ID', async () => { + const apiResponse = await supertest.get( + `/api/uptime/monitor/selected?monitorId=${monitorId}` + ); + expectFixtureEql(apiResponse.body, 'selected_monitor'); + }); + }); +} From daffda7286a4d31e183b36a74f816f055a5487db Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 9 Jan 2020 09:28:08 -0800 Subject: [PATCH 46/63] [DOCS] Adds licence expiry details (#54081) --- docs/management/managing-licenses.asciidoc | 189 +++++++++++++++++++-- 1 file changed, 173 insertions(+), 16 deletions(-) diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index bbe4b3b68e03b..0ad27e68f7fe9 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -1,28 +1,185 @@ [[managing-licenses]] -== License Management +== License management -When you install {kib}, it generates a Basic license -with no expiration date. Go to *Management > License Management* to view the -status of your license, start a 30-day trial, or install a new license. +When you install the default distribution of {kib}, you receive a basic license +with no expiration date. For the full list of free features that are included in +the basic license, see https://www.elastic.co/subscriptions[the subscription page]. -To learn more about the available license levels, -see https://www.elastic.co/subscriptions[the subscription page]. +If you want to try out the full set of platinum features, you can activate a +30-day trial license. Go to *Management > License Management* to view the +status of your license, start a trial, or install a new license. -You can activate a 30-day trial license to try out the full set of -https://www.elastic.co/subscriptions[Platinum features], including machine learning, -advanced security, alerting, graph capabilities, and more. +NOTE: You can start a trial only if your cluster has not already activated a +trial license for the current major product version. For example, if you have +already activated a trial for v6.0, you cannot start a new trial until +v7.0. You can, however, contact `info@elastic.co` to request an extended trial +license. -When you activate a new license level, new features will appear in the left sidebar +When you activate a new license level, new features appear in the left sidebar of the *Management* page. [role="screenshot"] image::images/management-license.png[] -At the end of the trial period, the Platinum features operate in a -{stack-ov}/license-expiration.html[degraded mode]. You can revert to a Basic -license, extend the trial, or purchase a subscription. +At the end of the trial period, the platinum features operate in a +<>. You can revert to a basic license, +extend the trial, or purchase a subscription. +TIP: If {security-features} are enabled, before you revert to a basic license or +install a gold or platinum license, you must configure Transport Layer Security +(TLS) in {es}. See {ref}/encrypting-communications.html[Encrypting communications]. +{kib} and the {ref}/start-basic.html[start basic API] provide a list of all of +the features that will no longer be supported if you revert to a basic license. -TIP: If {security-features} are enabled, before you revert to a Basic license or install -a Gold or Platinum license, you must configure Transport Layer Security (TLS) in {es}. -See {ref}/encrypting-communications.html[Encrypting communications]. \ No newline at end of file +[discrete] +[[update-license]] +=== Update your license + +You can update your license at runtime without shutting down your {es} nodes. +License updates take effect immediately. The license is provided as a _JSON_ +file that you install in {kib} or by using the +{ref}/update-license.html[update license API]. + +TIP: If you are using a basic or trial license, {security-features} are disabled +by default. In all other licenses, {security-features} are enabled by default; +you must secure the {stack} or disable the {security-features}. + +[discrete] +[[license-expiration]] +=== License expiration + +Your license is time based and expires at a future date. If you're using +{monitor-features} and your license will expire within 30 days, a license +expiration warning is displayed prominently. Warnings are also displayed on +startup and written to the {es} log starting 30 days from the expiration date. +These error messages tell you when the license expires and what features will be +disabled if you do not update the license. + +IMPORTANT: You should update your license as soon as possible. You are +essentially flying blind when running with an expired license. Access to the +cluster health and stats APIs is critical for monitoring and managing an {es} +cluster. + +[discrete] +[[expiration-beats]] +==== Beats + +* Beats will continue to poll centrally-managed configuration. + +[discrete] +[[expiration-elasticsearch]] +==== {es} + +// Upgrade API is disabled +* The deprecation API is disabled. +* SQL support is disabled. +* Aggregations provided by the analytics plugin are no longer usable. + +[discrete] +[[expiration-watcher]] +==== {stack} {alert-features} + +* The PUT and GET watch APIs are disabled. The DELETE watch API continues to work. +* Watches execute and write to the history. +* The actions of the watches do not execute. + +[discrete] +[[expiration-graph]] +==== {stack} {graph-features} + +* Graph explore APIs are disabled. + +[discrete] +[[expiration-ml]] +==== {stack} {ml-features} + +* APIs to create {anomaly-jobs}, open jobs, send data to jobs, create {dfeeds}, +and start {dfeeds} are disabled. +* All started {dfeeds} are stopped. +* All open {anomaly-jobs} are closed. +* APIs to create and start {dfanalytics-jobs} are disabled. +* Existing {anomaly-job} and {dfanalytics-job} results continue to be available +by using {kib} or APIs. + +[discrete] +[[expiration-monitoring]] +==== {stack} {monitor-features} + +* The agent stops collecting cluster and indices metrics. +* The agent stops automatically cleaning indices older than +`xpack.monitoring.history.duration`. + +[discrete] +[[expiration-security]] +==== {stack} {security-features} + +* Cluster health, cluster stats, and indices stats operations are blocked. +* All data operations (read and write) continue to work. + +Once the license expires, calls to the cluster health, cluster stats, and index +stats APIs fail with a `security_exception` and return a 403 HTTP status code. + +[source,sh] +----------------------------------------------------- +{ + "error": { + "root_cause": [ + { + "type": "security_exception", + "reason": "current license is non-compliant for [security]", + "license.expired.feature": "security" + } + ], + "type": "security_exception", + "reason": "current license is non-compliant for [security]", + "license.expired.feature": "security" + }, + "status": 403 +} +----------------------------------------------------- + +This message enables automatic monitoring systems to easily detect the license +failure without immediately impacting other users. + +[discrete] +[[expiration-logstash]] +==== {ls} pipeline management + +* Cannot create new pipelines or edit or delete existing pipelines from the UI. +* Cannot list or view existing pipelines from the UI. +* Cannot run Logstash instances which are registered to listen to existing pipelines. +//TBD: * Logstash will continue to poll centrally-managed pipelines + +[discrete] +[[expiration-kibana]] +==== {kib} + +* Users can still log into {kib}. +* {kib} works for data exploration and visualization, but some features +are disabled. +* The license management UI is available to easily upgrade your license. See +<> and <>. + +[discrete] +[[expiration-reporting]] +==== {kib} {report-features} + +* Reporting is no longer available in {kib}. +* Report generation URLs stop working. +* Existing reports are no longer accessible. + +[discrete] +[[expiration-rollups]] +==== {rollups-cap} + +* {rollup-jobs-cap} cannot be created or started. +* Existing {rollup-jobs} can be stopped and deleted. +* The get rollup caps and rollup search APIs continue to function. + +[discrete] +[[expiration-transforms]] +==== {transforms-cap} + +* {transforms-cap} cannot be created, previewed, started, or updated. +* Existing {transforms} can be stopped and deleted. +* Existing {transform} results continue to be available. From a6605f21cc81d24280e7cbc70bfaac3df0e343e1 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Thu, 9 Jan 2020 10:59:16 -0700 Subject: [PATCH 47/63] [Metrics UI & Logs UI] Deprecate the override fields in settings (#54206) --- .../fields_configuration_panel.tsx | 468 ++++++++++-------- 1 file changed, 257 insertions(+), 211 deletions(-) diff --git a/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx b/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx index e65753ef24e9e..e80b1489728cc 100644 --- a/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx +++ b/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx @@ -12,7 +12,10 @@ import { EuiFormRow, EuiSpacer, EuiTitle, + EuiCallOut, + EuiLink, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; @@ -38,259 +41,302 @@ export const FieldsConfigurationPanel = ({ tiebreakerFieldProps, timestampFieldProps, displaySettings, -}: FieldsConfigurationPanelProps) => ( - - -

- -

-
- - +}: FieldsConfigurationPanelProps) => { + const isHostValueDefault = hostFieldProps.value === 'host.name'; + const isContainerValueDefault = containerFieldProps.value === 'container.id'; + const isPodValueDefault = podFieldProps.value === 'kubernetes.pod.uid'; + const isTimestampValueDefault = timestampFieldProps.value === '@timestamp'; + const isTiebreakerValueDefault = tiebreakerFieldProps.value === '_doc'; + return ( + + +

-

- } - description={ - - } - > - +
+ + +

@timestamp, + documentationLink: ( + + + + ), + ecsLink: ( + + ECS + + ), }} /> +

+
+ + + + } - isInvalid={timestampFieldProps.isInvalid} - label={ + description={ } > - - - - {displaySettings === 'logs' && ( - <> - - - + helpText={ + @timestamp, + }} + /> } - description={ + isInvalid={timestampFieldProps.isInvalid} + label={ } > - _doc, - }} - /> + disabled={isLoading || isTimestampValueDefault} + readOnly={readOnly} + isLoading={isLoading} + {...timestampFieldProps} + /> + + + {displaySettings === 'logs' && ( + <> + + + } - isInvalid={tiebreakerFieldProps.isInvalid} - label={ + description={ } > - - - - - )} - {displaySettings === 'metrics' && ( - <> - - - - } - description={ - - } - > - container.id, - }} + helpText={ + _doc, + }} + /> + } + isInvalid={tiebreakerFieldProps.isInvalid} + label={ + + } + > + + + + + )} + {displaySettings === 'metrics' && ( + <> + + + } - isInvalid={containerFieldProps.isInvalid} - label={ + description={ } > - - - - - - - } - description={ - - } - > - host.name, - }} + helpText={ + container.id, + }} + /> + } + isInvalid={containerFieldProps.isInvalid} + label={ + + } + > + + + + + + } - isInvalid={hostFieldProps.isInvalid} - label={ + description={ } > - - - - - - - } - description={ - - } - > - kubernetes.pod.uid, - }} + helpText={ + host.name, + }} + /> + } + isInvalid={hostFieldProps.isInvalid} + label={ + + } + > + + + + + + } - isInvalid={podFieldProps.isInvalid} - label={ + description={ } > - - - - - )} -
-); + helpText={ + kubernetes.pod.uid, + }} + /> + } + isInvalid={podFieldProps.isInvalid} + label={ + + } + > + + +
+ + )} +
+ ); +}; From 719ff259fcfc21e0b13d908227cd016157aeb19b Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 9 Jan 2020 13:03:16 -0500 Subject: [PATCH 48/63] Add support for certificates in PKCS#12 (P12) key stores (#53810) Kibana now supports the usage of PKCS#12 (P12) key stores and trust stores for certificates and keys. --- ...ana-plugin-server.kibanaresponsefactory.md | 8 +- docs/settings/security-settings.asciidoc | 2 +- docs/setup/settings.asciidoc | 121 ++++-- .../monitoring/monitoring-kibana.asciidoc | 12 +- .../security/authentication/index.asciidoc | 3 + .../securing-communications/index.asciidoc | 132 +++---- package.json | 2 + packages/kbn-dev-utils/certs/README.md | 62 +++ packages/kbn-dev-utils/certs/ca.crt | 45 ++- .../kbn-dev-utils/certs/elasticsearch.crt | 43 ++- .../kbn-dev-utils/certs/elasticsearch.key | 50 +-- .../kbn-dev-utils/certs/elasticsearch.p12 | Bin 0 -> 3501 bytes .../certs/elasticsearch_emptypassword.p12 | Bin 0 -> 3333 bytes .../certs/elasticsearch_nopassword.p12 | Bin 0 -> 3543 bytes packages/kbn-dev-utils/certs/kibana.crt | 29 ++ packages/kbn-dev-utils/certs/kibana.key | 27 ++ packages/kbn-dev-utils/certs/kibana.p12 | Bin 0 -> 3463 bytes packages/kbn-dev-utils/src/certs.ts | 11 + packages/kbn-dev-utils/src/index.ts | 14 +- packages/kbn-es/src/cluster.js | 8 +- .../integration_tests/__fixtures__/es_bin.js | 2 + .../src/integration_tests/cluster.test.js | 14 +- packages/kbn-pm/dist/index.js | 16 + renovate.json5 | 8 + src/cli/serve/serve.js | 4 + .../deprecation/core_deprecations.test.ts | 31 ++ .../config/deprecation/core_deprecations.ts | 11 + .../elasticsearch_client_config.test.ts | 21 +- .../elasticsearch_client_config.ts | 13 +- .../elasticsearch_config.test.mocks.ts | 28 ++ .../elasticsearch_config.test.ts | 257 +++++++++++-- .../elasticsearch/elasticsearch_config.ts | 143 +++++-- .../elasticsearch_service.test.ts | 4 + .../__snapshots__/http_config.test.ts.snap | 20 +- src/core/server/http/http_config.test.ts | 215 +---------- src/core/server/http/http_server.test.ts | 19 +- src/core/server/http/http_tools.ts | 9 +- src/core/server/http/ssl_config.test.mocks.ts | 30 ++ src/core/server/http/ssl_config.test.ts | 363 ++++++++++++++++++ src/core/server/http/ssl_config.ts | 77 +++- src/core/server/server.api.md | 8 +- src/core/utils/crypto/__fixtures__/README.md | 44 +++ src/core/utils/crypto/__fixtures__/index.ts | 26 ++ src/core/utils/crypto/__fixtures__/no_ca.p12 | Bin 0 -> 2431 bytes .../utils/crypto/__fixtures__/no_cert.p12 | Bin 0 -> 2217 bytes src/core/utils/crypto/__fixtures__/no_key.p12 | Bin 0 -> 1939 bytes .../utils/crypto/__fixtures__/two_cas.p12 | Bin 0 -> 4215 bytes .../utils/crypto/__fixtures__/two_keys.p12 | Bin 0 -> 4781 bytes .../crypto/index.ts} | 3 +- src/core/utils/crypto/pkcs12.test.ts | 205 ++++++++++ src/core/utils/crypto/pkcs12.ts | 179 +++++++++ src/core/utils/index.ts | 1 + .../resources/bin/kibana-docker | 8 + .../__tests__/elasticsearch_proxy_config.js | 39 +- .../console/server/__tests__/fixtures/ca.crt | 1 - .../server/__tests__/fixtures/cert.crt | 1 - .../server/__tests__/fixtures/cert.key | 1 - .../console/server/__tests__/proxy_config.js | 33 +- .../server/elasticsearch_proxy_config.ts | 23 +- .../console/server/proxy_config.js | 7 +- .../server_integration/__fixtures__/README.md | 79 ++++ test/server_integration/__fixtures__/index.ts | 25 ++ .../__fixtures__/localhost.p12 | Bin 0 -> 4184 bytes .../__fixtures__/test_intermediate_ca.crt | 79 ++++ .../__fixtures__/test_root_ca.crt | 24 ++ test/server_integration/config.js | 4 +- test/server_integration/http/ssl/config.js | 15 +- .../http/ssl_redirect/config.js | 15 +- .../http/ssl_with_p12/config.js | 56 +++ .../http/ssl_with_p12/index.js | 28 ++ .../http/ssl_with_p12_intermediate/config.js | 56 +++ .../http/ssl_with_p12_intermediate/index.js | 28 ++ test/server_integration/services/index.js | 2 +- test/server_integration/services/supertest.js | 22 +- x-pack/legacy/plugins/security/README.md | 6 +- x-pack/test/pki_api_integration/config.ts | 6 +- .../pki_api_integration/fixtures/README.md | 17 +- .../pki_api_integration/fixtures/es_ca.key | 27 -- .../fixtures/first_client.p12 | Bin 3467 -> 3467 bytes .../fixtures/second_client.p12 | Bin 3469 -> 3477 bytes .../fixtures/idp_metadata.xml | 38 +- .../fixtures/saml_tools.ts | 3 +- yarn.lock | 12 + 83 files changed, 2308 insertions(+), 667 deletions(-) create mode 100644 packages/kbn-dev-utils/certs/README.md mode change 100755 => 100644 packages/kbn-dev-utils/certs/ca.crt mode change 100755 => 100644 packages/kbn-dev-utils/certs/elasticsearch.crt mode change 100755 => 100644 packages/kbn-dev-utils/certs/elasticsearch.key create mode 100644 packages/kbn-dev-utils/certs/elasticsearch.p12 create mode 100644 packages/kbn-dev-utils/certs/elasticsearch_emptypassword.p12 create mode 100644 packages/kbn-dev-utils/certs/elasticsearch_nopassword.p12 create mode 100644 packages/kbn-dev-utils/certs/kibana.crt create mode 100644 packages/kbn-dev-utils/certs/kibana.key create mode 100644 packages/kbn-dev-utils/certs/kibana.p12 create mode 100644 src/core/server/elasticsearch/elasticsearch_config.test.mocks.ts create mode 100644 src/core/server/http/ssl_config.test.mocks.ts create mode 100644 src/core/server/http/ssl_config.test.ts create mode 100644 src/core/utils/crypto/__fixtures__/README.md create mode 100644 src/core/utils/crypto/__fixtures__/index.ts create mode 100644 src/core/utils/crypto/__fixtures__/no_ca.p12 create mode 100644 src/core/utils/crypto/__fixtures__/no_cert.p12 create mode 100644 src/core/utils/crypto/__fixtures__/no_key.p12 create mode 100644 src/core/utils/crypto/__fixtures__/two_cas.p12 create mode 100644 src/core/utils/crypto/__fixtures__/two_keys.p12 rename src/core/{server/elasticsearch/elasticsearch_client_config.test.mocks.ts => utils/crypto/index.ts} (88%) create mode 100644 src/core/utils/crypto/pkcs12.test.ts create mode 100644 src/core/utils/crypto/pkcs12.ts delete mode 100644 src/legacy/core_plugins/console/server/__tests__/fixtures/ca.crt delete mode 100644 src/legacy/core_plugins/console/server/__tests__/fixtures/cert.crt delete mode 100644 src/legacy/core_plugins/console/server/__tests__/fixtures/cert.key create mode 100644 test/server_integration/__fixtures__/README.md create mode 100644 test/server_integration/__fixtures__/index.ts create mode 100644 test/server_integration/__fixtures__/localhost.p12 create mode 100644 test/server_integration/__fixtures__/test_intermediate_ca.crt create mode 100644 test/server_integration/__fixtures__/test_root_ca.crt create mode 100644 test/server_integration/http/ssl_with_p12/config.js create mode 100644 test/server_integration/http/ssl_with_p12/index.js create mode 100644 test/server_integration/http/ssl_with_p12_intermediate/config.js create mode 100644 test/server_integration/http/ssl_with_p12_intermediate/index.js delete mode 100644 x-pack/test/pki_api_integration/fixtures/es_ca.key diff --git a/docs/development/core/server/kibana-plugin-server.kibanaresponsefactory.md b/docs/development/core/server/kibana-plugin-server.kibanaresponsefactory.md index 85874f5ec16ba..2e496aa0c46fc 100644 --- a/docs/development/core/server/kibana-plugin-server.kibanaresponsefactory.md +++ b/docs/development/core/server/kibana-plugin-server.kibanaresponsefactory.md @@ -10,7 +10,7 @@ Set of helpers used to create `KibanaResponse` to form HTTP response on an incom ```typescript kibanaResponseFactory: { - custom: | Buffer | Stream | { + custom: | { message: string | Error; attributes?: Record | undefined; } | undefined>(options: CustomHttpResponseOptions) => KibanaResponse; @@ -21,9 +21,9 @@ kibanaResponseFactory: { conflict: (options?: ErrorHttpResponseOptions) => KibanaResponse; internalError: (options?: ErrorHttpResponseOptions) => KibanaResponse; customError: (options: CustomHttpResponseOptions) => KibanaResponse; - redirected: (options: RedirectResponseOptions) => KibanaResponse | Buffer | Stream>; - ok: (options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; - accepted: (options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; + redirected: (options: RedirectResponseOptions) => KibanaResponse>; + ok: (options?: HttpResponseOptions) => KibanaResponse>; + accepted: (options?: HttpResponseOptions) => KibanaResponse>; noContent: (options?: HttpResponseOptions) => KibanaResponse; } ``` diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index d6dd4378da1b7..16d68a7759f77 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -45,7 +45,7 @@ if this setting isn't the same for all instances of {kib}. `xpack.security.secureCookies`:: Sets the `secure` flag of the session cookie. The default value is `false`. It -is set to `true` if `server.ssl.certificate` and `server.ssl.key` are set. Set +is automatically set to `true` if `server.ssl.enabled` is set to `true`. Set this to `true` if SSL is configured outside of {kib} (for example, you are routing requests through a load balancer or proxy). diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index c98df6ca78356..535ad16978217 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -82,31 +82,52 @@ Elasticsearch nodes on startup. `elasticsearch.sniffOnConnectionFault:`:: *Default: false* Update the list of Elasticsearch nodes immediately following a connection fault. -`elasticsearch.ssl.alwaysPresentCertificate:`:: *Default: false* Controls -whether to always present the certificate specified by -`elasticsearch.ssl.certificate` when requested. This applies to all requests to -Elasticsearch, including requests that are proxied for end-users. Setting this -to `true` when Elasticsearch is using certificates to authenticate users can -lead to proxied requests for end-users being executed as the identity tied to -the configured certificate. - -`elasticsearch.ssl.certificate:` and `elasticsearch.ssl.key:`:: Optional -settings that provide the paths to the PEM-format SSL certificate and key files. -These files are used to verify the identity of Kibana to Elasticsearch and are -required when `xpack.security.http.ssl.client_authentication` in Elasticsearch is -set to `required`. - -`elasticsearch.ssl.certificateAuthorities:`:: Optional setting that enables you -to specify a list of paths to the PEM file for the certificate authority for -your Elasticsearch instance. - -`elasticsearch.ssl.keyPassphrase:`:: The passphrase that will be used to decrypt -the private key. This value is optional as the key may not be encrypted. - -`elasticsearch.ssl.verificationMode:`:: *Default: full* Controls the -verification of certificates presented by Elasticsearch. Valid values are `none`, -`certificate`, and `full`. `full` performs hostname verification, and -`certificate` does not. +`elasticsearch.ssl.alwaysPresentCertificate:`:: *Default: false* Controls whether to always present the certificate specified by +`elasticsearch.ssl.certificate` or `elasticsearch.ssl.keystore.path` when requested. This setting applies to all requests to Elasticsearch, +including requests that are proxied for end users. Setting this to `true` when Elasticsearch is using certificates to authenticate users can +lead to proxied requests for end users being executed as the identity tied to the configured certificate. + +`elasticsearch.ssl.certificate:` and `elasticsearch.ssl.key:`:: Paths to a PEM-encoded X.509 certificate and its private key, respectively. +When `xpack.security.http.ssl.client_authentication` in Elasticsearch is set to `required` or `optional`, the certificate and key are used +to prove Kibana's identity when it makes an outbound request to your Elasticsearch cluster. ++ +-- +NOTE: These settings cannot be used in conjunction with `elasticsearch.ssl.keystore.path`. +-- + +`elasticsearch.ssl.certificateAuthorities:`:: Paths to one or more PEM-encoded X.509 certificates. These certificates may consist of a root +certificate authority (CA), and one or more intermediate CAs, which make up a trusted certificate chain for Kibana. This chain is used to +establish trust when Kibana creates an SSL connection with your Elasticsearch cluster. In addition to this setting, trusted certificates may +be specified via `elasticsearch.ssl.keystore.path` and/or `elasticsearch.ssl.truststore.path`. + +`elasticsearch.ssl.keyPassphrase:`:: The passphrase that will be used to decrypt the private key that is specified via +`elasticsearch.ssl.key`. This value is optional, as the key may not be encrypted. + +`elasticsearch.ssl.keystore.path:`:: Path to a PKCS #12 file that contains an X.509 certificate with its private key. When +`xpack.security.http.ssl.client_authentication` in Elasticsearch is set to `required` or `optional`, the certificate and key are used to +prove Kibana's identity when it makes an outbound request to your Elasticsearch cluster. If the file contains any additional certificates, +those will be used as a trusted certificate chain for your Elasticsearch cluster. This chain is used to establish trust when Kibana creates +an SSL connection with your Elasticsearch cluster. In addition to this setting, trusted certificates may be specified via +`elasticsearch.ssl.certificateAuthorities` and/or `elasticsearch.ssl.truststore.path`. ++ +-- +NOTE: This setting cannot be used in conjunction with `elasticsearch.ssl.certificate` or `elasticsearch.ssl.key`. +-- + +`elasticsearch.ssl.keystore.password:`:: The password that will be used to decrypt the key store and its private key. If your key store has +no password, leave this unset. If your key store has an empty password, set this to `""`. + +`elasticsearch.ssl.truststore.path:`:: Path to a PKCS #12 trust store that contains one or more X.509 certificates. This may consist of a +root certificate authority (CA) and one or more intermediate CAs, which make up a trusted certificate chain for your Elasticsearch cluster. +This chain is used to establish trust when Kibana creates an SSL connection with your Elasticsearch cluster. In addition to this setting, +trusted certificates may be specified via `elasticsearch.ssl.certificateAuthorities` and/or `elasticsearch.ssl.keystore.path`. + +`elasticsearch.ssl.truststore.password:`:: The password that will be used to decrypt the trust store. If your trust store has no password, +leave this unset. If your trust store has an empty password, set this to `""`. + +`elasticsearch.ssl.verificationMode:`:: *Default: full* Controls the verification of certificates presented by Elasticsearch. Valid values +are `none`, `certificate`, and `full`. `full` performs hostname verification and `certificate` does not. This setting is used only when +traffic to Elasticsearch is encrypted, which is specified by using the HTTPS protocol in `elasticsearch.hosts`. `elasticsearch.startupTimeout:`:: *Default: 5000* Time in milliseconds to wait for Elasticsearch at Kibana startup before retrying. @@ -325,11 +346,19 @@ default is `true`. `server.socketTimeout:`:: *Default: "120000"* The number of milliseconds to wait before closing an inactive socket. -`server.ssl.certificate:` and `server.ssl.key:`:: Paths to the PEM-format SSL -certificate and SSL key files, respectively. +`server.ssl.certificate:` and `server.ssl.key:`:: Paths to a PEM-encoded X.509 certificate and its private key, respectively. These are used +when enabling SSL for inbound requests from web browsers to the Kibana server. ++ +-- +NOTE: These settings cannot be used in conjunction with `server.ssl.keystore.path`. +-- -`server.ssl.certificateAuthorities:`:: List of paths to PEM encoded certificate -files that should be trusted. +`server.ssl.certificateAuthorities:`:: Paths to one or more PEM-encoded X.509 certificates. These certificates may consist of a root +certificate authority (CA) and one or more intermediate CAs, which make up a trusted certificate chain for Kibana. This chain is used when a +web browser creates an SSL connection with the Kibana server; the certificate chain is sent to the browser along with the end-entity +certificate to establish trust. This chain is also used to determine whether client certificates should be trusted when PKI authentication +is enabled. In addition to this setting, trusted certificates may be specified via `server.ssl.keystore.path` and/or +`server.ssl.truststore.path`. `server.ssl.cipherSuites:`:: *Default: ECDHE-RSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-GCM-SHA384, DHE-RSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-SHA256, DHE-RSA-AES128-SHA256, ECDHE-RSA-AES256-SHA384, DHE-RSA-AES256-SHA384, ECDHE-RSA-AES256-SHA256, DHE-RSA-AES256-SHA256, HIGH,!aNULL, !eNULL, !EXPORT, !DES, !RC4, !MD5, !PSK, !SRP, !CAMELLIA*. Details on the format, and the valid options, are available via the @@ -339,12 +368,36 @@ https://www.openssl.org/docs/man1.0.2/apps/ciphers.html#CIPHER-LIST-FORMAT[OpenS connections. Valid values are `required`, `optional`, and `none`. `required` forces a client to present a certificate, while `optional` requests a client certificate but the client is not required to present one. -`server.ssl.enabled:`:: *Default: "false"* Enables SSL for outgoing requests -from the Kibana server to the browser. When set to `true`, -`server.ssl.certificate` and `server.ssl.key` are required. +`server.ssl.enabled:`:: *Default: "false"* Enables SSL for inbound requests from the browser to the Kibana server. When set to `true`, a +certificate and private key must be provided. These can be specified via `server.ssl.keystore.path` or the combination of +`server.ssl.certificate` and `server.ssl.key`. + +`server.ssl.keyPassphrase:`:: The passphrase that will be used to decrypt the private key that is specified via `server.ssl.key`. This value +is optional, as the key may not be encrypted. + +`server.ssl.keystore.path:`:: Path to a PKCS #12 file that contains an X.509 certificate with its private key. These are used when enabling +SSL for inbound requests from web browsers to the Kibana server. If the file contains any additional certificates, those will be used as a +trusted certificate chain for Kibana. This chain is used when a web browser creates an SSL connection with the Kibana server; the +certificate chain is sent to the browser along with the end-entity certificate to establish trust. This chain is also used to determine +whether client certificates should be trusted when PKI authentication is enabled. In addition to this setting, trusted certificates may be +specified via `server.ssl.certificateAuthorities` and/or `server.ssl.truststore.path`. ++ +-- +NOTE: This setting cannot be used in conjunction with `server.ssl.certificate` or `server.ssl.key`. +-- + +`server.ssl.keystore.password:`:: The password that will be used to decrypt the key store and its private key. If your key store has no +password, leave this unset. If your key store has an empty password, set this to `""`. + +`server.ssl.truststore.path:`:: Path to a PKCS #12 trust store that contains one or more X.509 certificates. These certificates may consist +of a root certificate authority (CA) and one or more intermediate CAs, which make up a trusted certificate chain for Kibana. This chain is +used when a web browser creates an SSL connection with the Kibana server; the certificate chain is sent to the browser along with the +end-entity certificate to establish trust. This chain is also used to determine whether client certificates should be trusted when PKI +authentication is enabled. In addition to this setting, trusted certificates may be specified via `server.ssl.certificateAuthorities` and/or +`server.ssl.keystore.path`. -`server.ssl.keyPassphrase:`:: The passphrase that will be used to decrypt the -private key. This value is optional as the key may not be encrypted. +`server.ssl.truststore.password:`:: The password that will be used to decrypt the trust store. If your trust store has no password, leave +this unset. If your trust store has an empty password, set this to `""`. `server.ssl.redirectHttpFromPort:`:: Kibana will bind to this port and redirect all http requests to https over the port configured as `server.port`. diff --git a/docs/user/monitoring/monitoring-kibana.asciidoc b/docs/user/monitoring/monitoring-kibana.asciidoc index d7af0d5c420a1..b5d263aed8346 100644 --- a/docs/user/monitoring/monitoring-kibana.asciidoc +++ b/docs/user/monitoring/monitoring-kibana.asciidoc @@ -96,7 +96,8 @@ used when {kib} sends monitoring data to the production cluster. .. Configure {kib} to encrypt communications between the {kib} server and the production cluster. This set up involves generating a server certificate and setting `server.ssl.*` and `elasticsearch.ssl.certificateAuthorities` settings -in the `kibana.yml` file on the {kib} server. For example: +in the `kibana.yml` file on the {kib} server. For example, using a PEM-formatted +certificate and private key: + -- [source,yaml] @@ -105,14 +106,19 @@ server.ssl.key: /path/to/your/server.key server.ssl.certificate: /path/to/your/server.crt -------------------------------------------------------------------------------- -If you are using your own certificate authority to sign certificates, specify -the location of the PEM file in the `kibana.yml` file: +If you are using your own certificate authority (CA) to sign certificates, +specify the location of the PEM file in the `kibana.yml` file: [source,yaml] -------------------------------------------------------------------------------- elasticsearch.ssl.certificateAuthorities: /path/to/your/cacert.pem -------------------------------------------------------------------------------- +NOTE: Alternatively, the PKCS #12 format can be used for the Kibana certificate +and key, along with any included CA certificates, by setting +`server.ssl.keystore.path`. If your CA certificate chain is in a separate trust +store, you can also use `server.ssl.truststore.path`. + For more information, see <>. -- diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index 2e2aaf688e8b6..05aabfc343be9 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -67,6 +67,9 @@ server.ssl.clientAuthentication: required xpack.security.authc.providers: [pki] -------------------------------------------------------------------------------- +NOTE: Trusted CAs can also be specified in a PKCS #12 keystore bundled with your Kibana server certificate/key using +`server.ssl.keystore.path` or in a separate trust store using `server.ssl.truststore.path`. + PKI support in {kib} is designed to be the primary (or sole) authentication method for users of that {kib} instance. However, you can configure both PKI and Basic authentication for the same {kib} instance: [source,yaml] diff --git a/docs/user/security/securing-communications/index.asciidoc b/docs/user/security/securing-communications/index.asciidoc index 6917a48909c7b..b370c35905bce 100644 --- a/docs/user/security/securing-communications/index.asciidoc +++ b/docs/user/security/securing-communications/index.asciidoc @@ -4,121 +4,115 @@ Encrypting communications ++++ -{kib} supports Transport Layer Security (TLS/SSL) encryption for client -requests. -//TBD: It is unclear what "client requests" are in this context. Is it just -// communication between the browser and the Kibana server or are we talking -// about other types of clients connecting to the Kibana server? - -If you are using {security} or a proxy that provides an HTTPS endpoint for {es}, -you can configure {kib} to access {es} via HTTPS. Thus, communications between -{kib} and {es} are also encrypted. - -. Configure {kib} to encrypt communications between the browser and the {kib} -server: +{kib} supports Transport Layer Security (TLS/SSL) encryption for all forms of data-in-transit. Browsers send traffic to {kib} and {kib} +sends traffic to {es}. These communications are configured separately. + +[[configuring-tls-browser-kib]] +==== Encrypting traffic between the browser and {kib} + +NOTE: You do not need to enable {security-features} for this type of encryption. + +. Obtain a server certificate and private key for {kib}. + -- -NOTE: You do not need to enable {security} for this type of encryption. +{kib} supports certificates/keys in both PKCS #12 key stores and PEM format. + +When you obtain a certificate, you must do at least one of the following: + +.. Set the certificate's `subjectAltName` to the hostname, fully-qualified domain name (FQDN), or IP address of the {kib} server. + +.. Set the certificate's Common Name (CN) to the {kib} server's hostname or FQDN. Using the server's IP address as the CN does not work. + +You may choose to generate a certificate and private key using {ref}/certutil.html[the {es} certutil tool]. If you already used certutil to +generate a certificate authority (CA), you would generate a certificate/key for Kibana like so (using the `--dns` param to set the +`subjectAltName`): + +[source,sh] +-------------------------------------------------------------------------------- +bin/elasticsearch-certutil cert --ca elastic-stack-ca.p12 --name kibana --dns localhost +-------------------------------------------------------------------------------- + +This will generate a certificate and private key in a PKCS #12 keystore named `kibana.p12`. -- -.. Generate a server certificate for {kib}. +. Enable TLS/SSL in `kibana.yml`: + -- -//TBD: Can we provide more information about how they generate the certificate? -//Would they be able to use something like the elasticsearch-certutil command? -You must either set the certificate's -`subjectAltName` to the hostname, fully-qualified domain name (FQDN), or IP -address of the {kib} server, or set the CN to the {kib} server's hostname -or FQDN. Using the server's IP address as the CN does not work. +[source,yaml] +-------------------------------------------------------------------------------- +server.ssl.enabled: true +-------------------------------------------------------------------------------- -- -.. Set the `server.ssl.enabled`, `server.ssl.key`, and `server.ssl.certificate` -properties in `kibana.yml`: +. Specify your server certificate and private key in `kibana.yml`: + -- +If your certificate and private key are in a PKCS #12 keystore, specify it like so: + [source,yaml] -------------------------------------------------------------------------------- -server.ssl.enabled: true -server.ssl.key: /path/to/your/server.key -server.ssl.certificate: /path/to/your/server.crt +server.ssl.keystore.path: "/path/to/your/keystore.p12" +server.ssl.keystore.password: "optional decryption password" +-------------------------------------------------------------------------------- + +Otherwise, if your certificate/key are in PEM format, specify them like so: + +[source,yaml] +-------------------------------------------------------------------------------- +server.ssl.certificate: "/path/to/your/server.crt" +server.ssl.key: "/path/to/your/server.key" +server.ssl.keyPassphrase: "optional decryption password" -------------------------------------------------------------------------------- After making these changes, you must always access {kib} via HTTPS. For example, https://localhost:5601. -// TBD: The reference information for server.ssl.enabled says it "enables SSL for -// outgoing requests from the Kibana server to the browser". Do we need to -// reiterate here that only one side of the communications is encrypted? - For more information, see <>. -- -. Configure {kib} to connect to {es} via HTTPS: -+ --- +[[configuring-tls-kib-es]] +==== Encrypting traffic between {kib} and {es} + NOTE: To perform this step, you must {ref}/configuring-security.html[enable the {es} {security-features}] or you must have a proxy that provides an HTTPS endpoint for {es}. --- - -.. Specify the HTTPS protocol in the `elasticsearch.hosts` setting in the {kib} -configuration file, `kibana.yml`: +. Specify the HTTPS URL in the `elasticsearch.hosts` setting in the {kib} configuration file, `kibana.yml`: + -- [source,yaml] -------------------------------------------------------------------------------- elasticsearch.hosts: ["https://.com:9200"] -------------------------------------------------------------------------------- --- - -.. If you are using your own CA to sign certificates for {es}, set the -`elasticsearch.ssl.certificateAuthorities` setting in `kibana.yml` to specify -the location of the PEM file. -+ --- -[source,yaml] --------------------------------------------------------------------------------- -elasticsearch.ssl.certificateAuthorities: /path/to/your/cacert.pem --------------------------------------------------------------------------------- -Setting the `certificateAuthorities` property lets you use the default -`verificationMode` option of `full`. -//TBD: Is this still true? It isn't mentioned in https://www.elastic.co/guide/en/kibana/master/settings.html +Using the HTTPS protocol results in a default `elasticsearch.ssl.verificationMode` option of `full`, which utilizes hostname verification. For more information, see <>. -- -. (Optional) If the Elastic {monitor-features} are enabled, configure {kib} to -connect to the {es} monitoring cluster via HTTPS: +. Specify the {es} cluster's CA certificate chain in `kibana.yml`: + -- -NOTE: To perform this step, you must -{ref}/configuring-security.html[enable the {es} {security-features}] or you -must have a proxy that provides an HTTPS endpoint for {es}. --- +If you are using your own CA to sign certificates for {es}, then you need to specify the CA certificate chain in {kib} to properly establish +trust in TLS connections. If your CA certificate chain is contained in a PKCS #12 trust store, specify it like so: -.. Specify the HTTPS URL in the `xpack.monitoring.elasticsearch.hosts` setting in -the {kib} configuration file, `kibana.yml` -+ --- [source,yaml] -------------------------------------------------------------------------------- -xpack.monitoring.elasticsearch.hosts: ["https://:9200"] +elasticsearch.ssl.truststore.path: "/path/to/your/truststore.p12" +elasticsearch.ssl.truststore.password: "optional decryption password" -------------------------------------------------------------------------------- --- -.. Specify the `xpack.monitoring.elasticsearch.ssl.*` settings in the -`kibana.yml` file. -+ --- -For example, if you are using your own certificate authority to sign -certificates, specify the location of the PEM file in the `kibana.yml` file: +Otherwise, if your CA certificate chain is in PEM format, specify each certificate like so: [source,yaml] -------------------------------------------------------------------------------- -xpack.monitoring.elasticsearch.ssl.certificateAuthorities: /path/to/your/cacert.pem +elasticsearch.ssl.certificateAuthorities: ["/path/to/your/cacert1.pem", "/path/to/your/cacert2.pem"] -------------------------------------------------------------------------------- + -- + +. (Optional) If the Elastic {monitor-features} are enabled, configure {kib} to connect to the {es} monitoring cluster via HTTPS. The steps +are the same as above, but each setting is prefixed by `xpack.monitoring.`. For example, `xpack.monitoring.elasticsearch.hosts`, +`xpack.monitoring.elasticsearch.ssl.truststore.path`, etc. diff --git a/package.json b/package.json index 0dbed9e432e99..be7c95c14934b 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "@kbn/ui-framework": "1.0.0", "@types/json-stable-stringify": "^1.0.32", "@types/lodash.clonedeep": "^4.5.4", + "@types/node-forge": "^0.9.0", "@types/react-grid-layout": "^0.16.7", "@types/recompose": "^0.30.5", "JSONStream": "1.3.5", @@ -217,6 +218,7 @@ "mustache": "2.3.2", "ngreact": "0.5.1", "node-fetch": "1.7.3", + "node-forge": "^0.9.1", "opn": "^5.5.0", "oppsy": "^2.0.0", "pegjs": "0.10.0", diff --git a/packages/kbn-dev-utils/certs/README.md b/packages/kbn-dev-utils/certs/README.md new file mode 100644 index 0000000000000..fdf7892789404 --- /dev/null +++ b/packages/kbn-dev-utils/certs/README.md @@ -0,0 +1,62 @@ +# Development certificates + +Kibana includes several development certificates to enable easy setup of TLS-encrypted communications with Elasticsearch. + +_Note: these certificates should **never** be used in production._ + +## Certificate information + +Certificates and keys are provided in multiple formats. These can be used by other packages to set up a new Elastic Stack with Kibana and Elasticsearch. The Certificate Authority (CA) private key is intentionally omitted from this package. + +### PEM + +* `ca.crt` -- A [PEM-formatted](https://tools.ietf.org/html/rfc1421) [X.509](https://tools.ietf.org/html/rfc5280) certificate that is used as a CA. +* `elasticsearch.crt` -- A PEM-formatted X.509 certificate and public key for Elasticsearch. +* `elasticsearch.key` -- A PEM-formatted [PKCS #1](https://tools.ietf.org/html/rfc8017) private key for Elasticsearch. +* `kibana.crt` -- A PEM-formatted X.509 certificate and public key for Kibana. +* `kibana.key` -- A PEM-formatted PKCS #1 private key for Kibana. + +### PKCS #12 + +* `elasticsearch.p12` -- A [PKCS #12](https://tools.ietf.org/html/rfc7292) encrypted key store / trust store that contains `ca.crt`, `elasticsearch.crt`, and a [PKCS #8](https://tools.ietf.org/html/rfc5208) encrypted version of `elasticsearch.key`. +* `kibana.p12` -- A PKCS #12 encrypted key store / trust store that contains `ca.crt`, `kibana.crt`, and a PKCS #8 encrypted version of `kibana.key`. + +The password used for both of these is "storepass". Other copies are also provided for testing purposes: + +* `elasticsearch_emptypassword.p12` -- The same PKCS #12 key store, encrypted with an empty password. +* `elasticsearch_nopassword.p12` -- The same PKCS #12 key store, not encrypted with a password. + +## Certificate generation + +[Elasticsearch cert-util](https://www.elastic.co/guide/en/elasticsearch/reference/current/certutil.html) and [OpenSSL](https://www.openssl.org/) were used to generate these certificates. The following commands were used from the root directory of Elasticsearch: + +``` +# Generate the PKCS #12 keystore for a CA, valid for 50 years +bin/elasticsearch-certutil ca -days 18250 --pass castorepass + +# Generate the PKCS #12 keystore for Elasticsearch and sign it with the CA +bin/elasticsearch-certutil cert -days 18250 --ca elastic-stack-ca.p12 --ca-pass castorepass --name elasticsearch --dns localhost --pass storepass + +# Generate the PKCS #12 keystore for Kibana and sign it with the CA +bin/elasticsearch-certutil cert -days 18250 --ca elastic-stack-ca.p12 --ca-pass castorepass --name kibana --dns localhost --pass storepass + +# Copy the PKCS #12 keystore for Elasticsearch with an empty password +openssl pkcs12 -in elasticsearch.p12 -nodes -passin pass:"storepass" -passout pass:"" | openssl pkcs12 -export -out elasticsearch_emptypassword.p12 -passout pass:"" + +# Manually create "elasticsearch_nopassword.p12" -- this can be done on macOS by importing the P12 key store into the Keychain and exporting it again + +# Extract the PEM-formatted X.509 certificate for the CA +openssl pkcs12 -in elasticsearch.p12 -out ca.crt -cacerts -passin pass:"storepass" -passout pass: + +# Extract the PEM-formatted PKCS #1 private key for Elasticsearch +openssl pkcs12 -in elasticsearch.p12 -nocerts -passin pass:"storepass" -passout pass:"keypass" | openssl rsa -passin pass:keypass -out elasticsearch.key + +# Extract the PEM-formatted X.509 certificate for Elasticsearch +openssl pkcs12 -in elasticsearch.p12 -out elasticsearch.crt -clcerts -passin pass:"storepass" -passout pass: + +# Extract the PEM-formatted PKCS #1 private key for Kibana +openssl pkcs12 -in kibana.p12 -nocerts -passin pass:"storepass" -passout pass:"keypass" | openssl rsa -passin pass:keypass -out kibana.key + +# Extract the PEM-formatted X.509 certificate for Kibana +openssl pkcs12 -in kibana.p12 -out kibana.crt -clcerts -passin pass:"storepass" -passout pass: +``` diff --git a/packages/kbn-dev-utils/certs/ca.crt b/packages/kbn-dev-utils/certs/ca.crt old mode 100755 new mode 100644 index 3e964823c5086..217935b8d83f6 --- a/packages/kbn-dev-utils/certs/ca.crt +++ b/packages/kbn-dev-utils/certs/ca.crt @@ -1,20 +1,29 @@ +Bag Attributes + friendlyName: elasticsearch + localKeyID: 54 69 6D 65 20 31 35 37 37 34 36 36 31 39 38 30 33 37 +Key Attributes: +Bag Attributes + friendlyName: ca + 2.16.840.1.113894.746875.1.1: +subject=/CN=Elastic Certificate Tool Autogenerated CA +issuer=/CN=Elastic Certificate Tool Autogenerated CA -----BEGIN CERTIFICATE----- -MIIDSjCCAjKgAwIBAgIVAOgxLlE1RMGl2fYgTKDznvDL2vboMA0GCSqGSIb3DQEB -CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu -ZXJhdGVkIENBMB4XDTE5MDcxMTE3MzQ0OFoXDTIyMDcxMDE3MzQ0OFowNDEyMDAG -A1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5lcmF0ZWQgQ0Ew -ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCNImKp/A9l++Ac7U5lvHOA -+fYRb8p7AgdfKBMB0v3bo+bpHjkbkf3vYHjo1xJSg5ls6EPK+Do4owkAgKJdrznI -5/efJOjgA+ylH4rgAfrRIQmiFEWZnAv86vJ+Iq83mfkPELb4dvXCi7AFQkzoM/rY -Lbi97xha5bA2SEmpYp7VhBTM9zWy+q9Tm5odPO8u2n75GpIM2RwipaXlL0ink+06 -/oweQJoivaDgpDOmUXCFPmpV3VCdhUGxDQPyG0upQkF+NbQoei4RmluPEmVz4S7I -TFLWjX7LeZVP63bJkcCgiq6Hm97kDtr9EYlPKhHm7UMWzhNzHbfvySMDzqAJC0KX -AgMBAAGjUzBRMB0GA1UdDgQWBBRKqaaQ/+jT+ipPLJe7qekp1N/zizAfBgNVHSME -GDAWgBRKqaaQ/+jT+ipPLJe7qekp1N/zizAPBgNVHRMBAf8EBTADAQH/MA0GCSqG -SIb3DQEBCwUAA4IBAQA7Gcq8h8yDXvepfKUAcTTMCBZkI+g3qE1gfRwjW7587CIj -xnrzEqANU+Q1lv7IeQ158HiduDUMZfnvpuNwkf0HkqnRWb57RwfVdCAlAeZmzipq -5ZJWlIW4dbmk57nGLg4fCszedi0uSGytZ2/BUdpWyC0fAM97h7Agtr4xGGKMEL67 -uB55ijt61V62HZ5wWXWNO9m+wfmdnt+YQViQJHtpYz1oOmWhY3dpitZLfWs1sLLD -w3CZOhmWX7+P7+HlCkSBF4swzHOCI3THyX61NbLxju8VkTAjwbZPq4EOnVKnO6kr -RdwQVnzKnqG5fxfSGknNahy0pOhJHZlGLwECRlgF +MIIDSzCCAjOgAwIBAgIUW0brhEtYK3tUBYlXnUa+AMmAX6kwDQYJKoZIhvcNAQEL +BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l +cmF0ZWQgQ0EwIBcNMTkxMjI3MTcwMjMyWhgPMjA2OTEyMTQxNzAyMzJaMDQxMjAw +BgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2VuZXJhdGVkIENB +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAplO5m5Xy8xERyA0/G5SM +Nu2QXkfS+m7ZTFjSmtwqX7BI1I6ISI4Yw8QxzcIgSbEGlSqb7baeT+A/1JQj0gZN +KOnKbazl+ujVRJpsfpt5iUsnQyVPheGekcHkB+9WkZPgZ1oGRENr/4Eb1VImQf+Y +yo/FUj8X939tYW0fficAqYKv8/4NWpBUbeop8wsBtkz738QKlmPkMwC4FbuF2/bN +vNuzQuRbGMVmPeyivZJRfDAMKExoXjCCLmbShdg4dUHsUjVeWQZ6s4vbims+8qF9 +b4bseayScQNNU3hc5mkfhEhSM0KB0lDpSvoCxuXvXzb6bOk7xIdYo+O4vHUhvSkQ +mwIDAQABo1MwUTAdBgNVHQ4EFgQUGu0mDnvDRnBdNBG8DxwPdWArB0kwHwYDVR0j +BBgwFoAUGu0mDnvDRnBdNBG8DxwPdWArB0kwDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAQEASv/FYOwWGnQreH8ulcVupGeZj25dIjZiuKfJmslH8QN/ +pVCIzAxNZjGjCpKxbJoCu5U9USaBylbhigeBJEq4wmYTs/WPu4uYMgDj0MILuHin +RQqgEVG0uADGEgH2nnk8DeY8gQvGpJRQGlXNK8pb+pCsy6F8k/svGOeBND9osHfU +CVEo5nXjfq6JCFt6hPx7kl4h3/j3C4wNy/Dv/QINdpPsl6CnF17Q9R9d60WFv42/ +pkl7W1hszCG9foNJOJabuWfVoPkvKQjoCvPitZt/hCaFZAW49PmAVhK+DAohQ91l +TZhDmYqHoXNiRDQiUT68OS7RlfKgNpr/vMTZXDxpmw== -----END CERTIFICATE----- diff --git a/packages/kbn-dev-utils/certs/elasticsearch.crt b/packages/kbn-dev-utils/certs/elasticsearch.crt old mode 100755 new mode 100644 index b30e11e9bbce1..87ba02019903f --- a/packages/kbn-dev-utils/certs/elasticsearch.crt +++ b/packages/kbn-dev-utils/certs/elasticsearch.crt @@ -1,20 +1,29 @@ +Bag Attributes + friendlyName: elasticsearch + localKeyID: 54 69 6D 65 20 31 35 37 37 34 36 36 31 39 38 30 33 37 +Key Attributes: +Bag Attributes + friendlyName: elasticsearch + localKeyID: 54 69 6D 65 20 31 35 37 37 34 36 36 31 39 38 30 33 37 +subject=/CN=elasticsearch +issuer=/CN=Elastic Certificate Tool Autogenerated CA -----BEGIN CERTIFICATE----- -MIIDRDCCAiygAwIBAgIVAI8V1fwvXKykKtp5k0cLpTOtY+DVMA0GCSqGSIb3DQEB +MIIDQDCCAiigAwIBAgIVAI93OQE6tZffPyzenSg3ljE3JJBzMA0GCSqGSIb3DQEB CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu -ZXJhdGVkIENBMB4XDTE5MDcxMTE3MzUxOFoXDTIyMDcxMDE3MzUxOFowGDEWMBQG -A1UEAxMNZWxhc3RpY3NlYXJjaDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC -ggEBALW+8gV6m6wYmTZmrXzNWKElE+ePkkikCviNfuWonWqxgAoWpAwAx2FvdhP3 -UDFbe38ydJX4oDgXeC25vdIR6z2uqzx+GXSNSybO7luuOUYQOP4Xf5Cj3zzXXMyu -nY1nZTVsChI9jAMz4cZZdUd04f4r4TBNxrFCcVR0uec5RGRXuP8rSQd9AbYFUVYf -jJeLb24asghb2Ku+c2JGvMqPEXFWFGOXFhUoIbRjCJNTDcr1ZXPof3+fO1l6HmhT -QBSqC4IZL8XqANltDT4tCQDD8L9+ckWJD8MP3wPkPUGZId2gLu++hrb9YfiP2upq -N/f3P7l5Fcisw1iwQC4+DGMTyfcCAwEAAaNpMGcwHQYDVR0OBBYEFGuiGk8HLpG2 -MyA24/J+GwxT32ikMB8GA1UdIwQYMBaAFEqpppD/6NP6Kk8sl7up6SnU3/OLMBoG -A1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAJBgNVHRMEAjAAMA0GCSqGSIb3DQEB -CwUAA4IBAQB8yfY0edAgq2KnJNWyl8NpHNfqtM27+/LR2V8OxVwxV1hc4ZilczLu -CXeqP9uqBVjcck6fvLrjy4LhSG0V05j51UMJ1FjFVTBuhlrDcd3j8848yWrmyz8z -vPYYY2vIN9d1NsBgufULwliBT4UJchsYE8xT5ayAzGHKCTlzHGHMTPzYjwac8nbT -nd2u+6h0OQOJn6K4v+RfXtN4EA8ZUrYxUkqHNS3cFB5sxH7JQGi25XJc5MfxyCwY -YOukxbN85ew861N6oVd+W+nGJu8WOLU88/uvCv+dLhnAlnnIOLqvmrD5m7gFsFO9 -Z7Xz/U1SbNipWy9OLOhqq2Ja59j8p9e5 +ZXJhdGVkIENBMCAXDTE5MTIyNzE3MDMxN1oYDzIwNjkxMjE0MTcwMzE3WjAYMRYw +FAYDVQQDEw1lbGFzdGljc2VhcmNoMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA2EkPfvE3ZNMjHCAQZhpImoXBCIN6KavvJSbVHRtLzAXB4wxige+vFQWb +4umqPeEeVH7FvrsRqn24tUgGIkag9p9AOwYxfcT3vwNqcK/EztIlYFs72pmYg7Ez +s6+qLc/YSLOT3aMoHKDHE93z1jYIDGccyjGbv9NsdgCbLHD0TQuqm+7pKy1MZoJm +0qn4KYw4kXakVNWlxm5GIwr8uqU/w4phrikcOOWqRzsxByoQajypLOA4eD/uWnI2 +zGyPQy7Bkxojiy1ss0CVlrl8fJgcjC4PONpm1ibUSX3SoZ8PopPThR6gvvwoQolR +rYu4+D+rsX7q/ldA6vBOiHBD8r4QoQIDAQABo2MwYTAdBgNVHQ4EFgQUSlIMCYYd +e72A0rUqaCkjVPkGPIwwHwYDVR0jBBgwFoAUGu0mDnvDRnBdNBG8DxwPdWArB0kw +FAYDVR0RBA0wC4IJbG9jYWxob3N0MAkGA1UdEwQCMAAwDQYJKoZIhvcNAQELBQAD +ggEBAImbzBVAEjiLRsNDLP7QAl0k7lVmfQRFz5G95ZTAUSUgbqqymDvry47yInFF +3o12TuI1GxK5zHzi+qzpJLyrnGwGK5JBR+VGxIBBKFVcFh1WNGKV6kSO/zBzO7PO +4Jw4G7By/ImWvS0RBhBUQ9XbQZN3WcVkVVV8UQw5Y7JoKtM+fzyEKXKRCTsvgH+h +3+fUBgqwal2Mz4KPH57Jrtk209dtn7tnQxHTNLo0niHyEcfrpuG3YFqTwekr+5FF +FniIcYHPGjag1WzLIdyhe88FFpuav19mlCaxBACc7t97v+euSVUWnsKpy4dLydpv +NxJiI9eWbJZ7f5VM7o64pm7U1cU= -----END CERTIFICATE----- diff --git a/packages/kbn-dev-utils/certs/elasticsearch.key b/packages/kbn-dev-utils/certs/elasticsearch.key old mode 100755 new mode 100644 index 1013ce3971246..9ae4e314630d1 --- a/packages/kbn-dev-utils/certs/elasticsearch.key +++ b/packages/kbn-dev-utils/certs/elasticsearch.key @@ -1,27 +1,27 @@ -----BEGIN RSA PRIVATE KEY----- -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 -----END RSA PRIVATE KEY----- diff --git a/packages/kbn-dev-utils/certs/elasticsearch.p12 b/packages/kbn-dev-utils/certs/elasticsearch.p12 new file mode 100644 index 0000000000000000000000000000000000000000..02a9183cd8a50e166b7b0500caf215a0a8dcd6b6 GIT binary patch literal 3501 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 L$;zsL0Kk6%be@sh literal 0 HcmV?d00001 diff --git a/packages/kbn-dev-utils/certs/kibana.crt b/packages/kbn-dev-utils/certs/kibana.crt new file mode 100644 index 0000000000000..1c83be587bff9 --- /dev/null +++ b/packages/kbn-dev-utils/certs/kibana.crt @@ -0,0 +1,29 @@ +Bag Attributes + friendlyName: kibana + localKeyID: 54 69 6D 65 20 31 35 37 37 34 36 36 32 32 33 30 33 39 +Key Attributes: +Bag Attributes + friendlyName: kibana + localKeyID: 54 69 6D 65 20 31 35 37 37 34 36 36 32 32 33 30 33 39 +subject=/CN=kibana +issuer=/CN=Elastic Certificate Tool Autogenerated CA +-----BEGIN CERTIFICATE----- +MIIDOTCCAiGgAwIBAgIVANNWkg9lzNiLqNkMFhFKHcXyaZmqMA0GCSqGSIb3DQEB +CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu +ZXJhdGVkIENBMCAXDTE5MTIyNzE3MDM0MloYDzIwNjkxMjE0MTcwMzQyWjARMQ8w +DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQ +wYYbQtbRBKJ4uNZc2+IgRU+7NNL21ZebQlEIMgK7jAqOMrsW2b5DATz41Fd+GQFU +FUYYjwo+PQj6sJHshOJo/gNb32HrydvMI7YPvevkszkuEGCfXxQ3Dw2RTACLgD0Q +OCkwHvn3TMf0loloV/ePGWaZDYZaXi3a5DdWi/HFFoJysgF0JV2f6XyKhJkGaEfJ +s9pWX269zH/XQvGNx4BEimJpYB8h4JnDYPFIiQdqj+sl2b+kS1hH9kL5gBAMXjFU +vcNnX+PmyTjyJrGo75k0ku+spBf1bMwuQt3uSmM+TQIXkvFDmS0DOVESrpA5EC1T +BUGRz6o/I88Xx4Mud771AgMBAAGjYzBhMB0GA1UdDgQWBBQLB1Eo23M3Ss8MsFaz +V+Twcb3PmDAfBgNVHSMEGDAWgBQa7SYOe8NGcF00EbwPHA91YCsHSTAUBgNVHREE +DTALgglsb2NhbGhvc3QwCQYDVR0TBAIwADANBgkqhkiG9w0BAQsFAAOCAQEAnEl/ +z5IElIjvkK4AgMPrNcRlvIGDt2orEik7b6Jsq6/RiJQ7cSsYTZf7xbqyxNsUOTxv ++frj47MEN448H2nRvUxH29YR3XygV5aEwADSAhwaQWn0QfWTCZbJTmSoNEDtDOzX +TGDlAoCD9s9Xz9S1JpxY4H+WWRZrBSDM6SC1c6CzuEeZRuScNAjYD5mh2v6fOlSy +b8xJWSg0AFlJPCa3ZsA2SKbNqI0uNfJTnkXRm88Z2NHcgtlADbOLKauWfCrpgsCk +cZgo6yAYkOM148h/8wGla1eX+iE1R72NUABGydu8MSQKvc0emWJkGsC1/KqPlf/O +eOUsdwn1yDKHRxDHyA== +-----END CERTIFICATE----- diff --git a/packages/kbn-dev-utils/certs/kibana.key b/packages/kbn-dev-utils/certs/kibana.key new file mode 100644 index 0000000000000..4a4e6b4cb8c36 --- /dev/null +++ b/packages/kbn-dev-utils/certs/kibana.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAkMGGG0LW0QSieLjWXNviIEVPuzTS9tWXm0JRCDICu4wKjjK7 +Ftm+QwE8+NRXfhkBVBVGGI8KPj0I+rCR7ITiaP4DW99h68nbzCO2D73r5LM5LhBg +n18UNw8NkUwAi4A9EDgpMB7590zH9JaJaFf3jxlmmQ2GWl4t2uQ3VovxxRaCcrIB +dCVdn+l8ioSZBmhHybPaVl9uvcx/10LxjceARIpiaWAfIeCZw2DxSIkHao/rJdm/ +pEtYR/ZC+YAQDF4xVL3DZ1/j5sk48iaxqO+ZNJLvrKQX9WzMLkLd7kpjPk0CF5Lx +Q5ktAzlREq6QORAtUwVBkc+qPyPPF8eDLne+9QIDAQABAoIBAHl9suxWYKz00te3 +alJtSZAEHDLm1tjL034/XnseXiTCGGnYMiWvgnwCIgZFUVlH61GCuV4LT3GFEHA2 +mYKE1PGBn5gQF8MpnAvtPPRhVgaQVUFQBYg86F59h8mWnC545sciG4+DsA/apUem +wJSOn/u+Odni/AwEV0ALolZFBhl+0rccSr+6paJnzJ7QNiIn6EWbgb0n9WXqkhap +TqoPclBHm0ObeBI6lNyfvBZ8HB3hyjWZInNCaAs9DnkNPh4evuttUn/KlOPOVn9r +xz2UYsmVW6E+yPXUpSYkFQN9aaPF6alOz8PIfF8Wit7pmZMmInluGcwi/us9+ZTN +8gNvpoECgYEA0KC7XEoXRsBTN4kPznkGftvj1dtgB35W/HxXNouArQQjCbLhqcsA +jqaK0f+stYzSWZXGsKl9yQU9KA7u/wCHmLep70l7WsYYUKdkhWouK0HU5MeeLwB0 +N4ekQOQuQGqelqMo7IG2hQhTYD9PB4F3G0Sz1FgdObfuGPKfvNFVjckCgYEAsaAA +IY/TpRBWeWZfyXrnkp3atOPzkdpjb6cfT8Kib9bIECXr7ULUxA5QANX05ofodhsW +3+7iW5wicyZ1VNVEsPRL0aw7YUbNpBvob8faBUZ2KEdKQr42IfVOo7TQnvVXtumR +UE+dNvWUL2PbL0wMxD1XbMSmOze/wF8X2CeyDc0CgYBQnLqol2xVBz1gaRJ1emgb +HoXzfVemrZeY6cadKdwnfkC3n6n4fJsTg6CCMiOe5vHkca4bVvJmeSK/Vr3cRG0g +gl8kOaVzVrXQfE2oC3YZes9zMvqZOLivODcsZ77DXy82D4dhk2FeF/B3cR7tTIYk +QDCoLP/l7H8QnrdAMza2mQKBgDODwuX475ncviehUEB/26+DBo4V2ms/mj0kjAk2 +2qNy+DzuspjyHADsYbmMU+WUHxA51Q2HG7ET/E3HJpo+7BgiEecye1pADZ391hCt +Nob3I4eU/W2T+uEoYvFJnIOthg3veYyAOolY+ewwmr4B4WX8oGFUOx3Lklo5ehHf +mV01AoGBAI/c6OoHdcqQsZxKlxDNLyB2bTbowAcccoZIOjkC5fkkbsmMDLfScBfW +Q4YYJsmJBdrWNvo7jCl17Mcc4Is3RlmHDrItRkaZj+ehqAN3ejrnPLdgYeW/5XDK +e7yBj7oJd4oKZc59jVytdHvo5R8K0QohAv9gQEZ/tdypX+xWe+5E +-----END RSA PRIVATE KEY----- diff --git a/packages/kbn-dev-utils/certs/kibana.p12 b/packages/kbn-dev-utils/certs/kibana.p12 new file mode 100644 index 0000000000000000000000000000000000000000..06bbd23881290b8952db722041a5da7553546560 GIT binary patch literal 3463 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 literal 0 HcmV?d00001 diff --git a/packages/kbn-dev-utils/src/certs.ts b/packages/kbn-dev-utils/src/certs.ts index 0d340e4e8c906..f72e3ee547b5c 100644 --- a/packages/kbn-dev-utils/src/certs.ts +++ b/packages/kbn-dev-utils/src/certs.ts @@ -22,3 +22,14 @@ import { resolve } from 'path'; export const CA_CERT_PATH = resolve(__dirname, '../certs/ca.crt'); export const ES_KEY_PATH = resolve(__dirname, '../certs/elasticsearch.key'); export const ES_CERT_PATH = resolve(__dirname, '../certs/elasticsearch.crt'); +export const ES_P12_PATH = resolve(__dirname, '../certs/elasticsearch.p12'); +export const ES_P12_PASSWORD = 'storepass'; +export const ES_EMPTYPASSWORD_P12_PATH = resolve( + __dirname, + '../certs/elasticsearch_emptypassword.p12' +); +export const ES_NOPASSWORD_P12_PATH = resolve(__dirname, '../certs/elasticsearch_nopassword.p12'); +export const KBN_KEY_PATH = resolve(__dirname, '../certs/kibana.key'); +export const KBN_CERT_PATH = resolve(__dirname, '../certs/kibana.crt'); +export const KBN_P12_PATH = resolve(__dirname, '../certs/kibana.p12'); +export const KBN_P12_PASSWORD = 'storepass'; diff --git a/packages/kbn-dev-utils/src/index.ts b/packages/kbn-dev-utils/src/index.ts index dc12613cf2a9e..2fc29b71b262e 100644 --- a/packages/kbn-dev-utils/src/index.ts +++ b/packages/kbn-dev-utils/src/index.ts @@ -25,7 +25,19 @@ export { ToolingLogCollectingWriter, } from './tooling_log'; export { createAbsolutePathSerializer } from './serializers'; -export { CA_CERT_PATH, ES_KEY_PATH, ES_CERT_PATH } from './certs'; +export { + CA_CERT_PATH, + ES_KEY_PATH, + ES_CERT_PATH, + ES_P12_PATH, + ES_P12_PASSWORD, + ES_EMPTYPASSWORD_P12_PATH, + ES_NOPASSWORD_P12_PATH, + KBN_KEY_PATH, + KBN_CERT_PATH, + KBN_P12_PATH, + KBN_P12_PASSWORD, +} from './certs'; export { run, createFailError, createFlagError, combineErrors, isFailError, Flags } from './run'; export { REPO_ROOT } from './repo_root'; export { KbnClient } from './kbn_client'; diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 665f80e3802e3..ceb4a5b6aece1 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -35,7 +35,7 @@ const { createCliError } = require('./errors'); const { promisify } = require('util'); const treeKillAsync = promisify(require('tree-kill')); const { parseSettings, SettingsFilter } = require('./settings'); -const { CA_CERT_PATH, ES_KEY_PATH, ES_CERT_PATH } = require('@kbn/dev-utils'); +const { CA_CERT_PATH, ES_P12_PATH, ES_P12_PASSWORD } = require('@kbn/dev-utils'); const readFile = util.promisify(fs.readFile); // listen to data on stream until map returns anything but undefined @@ -261,9 +261,9 @@ exports.Cluster = class Cluster { const esArgs = [].concat(options.esArgs || []); if (this._ssl) { esArgs.push('xpack.security.http.ssl.enabled=true'); - esArgs.push(`xpack.security.http.ssl.key=${ES_KEY_PATH}`); - esArgs.push(`xpack.security.http.ssl.certificate=${ES_CERT_PATH}`); - esArgs.push(`xpack.security.http.ssl.certificate_authorities=${CA_CERT_PATH}`); + esArgs.push(`xpack.security.http.ssl.keystore.path=${ES_P12_PATH}`); + esArgs.push(`xpack.security.http.ssl.keystore.type=PKCS12`); + esArgs.push(`xpack.security.http.ssl.keystore.password=${ES_P12_PASSWORD}`); } const args = parseSettings(extractConfigFiles(esArgs, installPath, { log: this._log }), { diff --git a/packages/kbn-es/src/integration_tests/__fixtures__/es_bin.js b/packages/kbn-es/src/integration_tests/__fixtures__/es_bin.js index d3181a748ffbb..d374abe5db068 100644 --- a/packages/kbn-es/src/integration_tests/__fixtures__/es_bin.js +++ b/packages/kbn-es/src/integration_tests/__fixtures__/es_bin.js @@ -34,6 +34,8 @@ if (!start) { let serverUrl; const server = createServer( { + // Note: the integration uses the ES_P12_PATH, but that keystore contains + // the same key/cert as ES_KEY_PATH and ES_CERT_PATH key: ssl ? fs.readFileSync(ES_KEY_PATH) : undefined, cert: ssl ? fs.readFileSync(ES_CERT_PATH) : undefined, }, diff --git a/packages/kbn-es/src/integration_tests/cluster.test.js b/packages/kbn-es/src/integration_tests/cluster.test.js index dd570e27e3282..dfbc04477bd40 100644 --- a/packages/kbn-es/src/integration_tests/cluster.test.js +++ b/packages/kbn-es/src/integration_tests/cluster.test.js @@ -17,7 +17,7 @@ * under the License. */ -const { ToolingLog, CA_CERT_PATH, ES_KEY_PATH, ES_CERT_PATH } = require('@kbn/dev-utils'); +const { ToolingLog, ES_P12_PATH, ES_P12_PASSWORD } = require('@kbn/dev-utils'); const execa = require('execa'); const { Cluster } = require('../cluster'); const { installSource, installSnapshot, installArchive } = require('../install'); @@ -252,9 +252,9 @@ describe('#start(installPath)', () => { const config = extractConfigFiles.mock.calls[0][0]; expect(config).toContain('xpack.security.http.ssl.enabled=true'); - expect(config).toContain(`xpack.security.http.ssl.key=${ES_KEY_PATH}`); - expect(config).toContain(`xpack.security.http.ssl.certificate=${ES_CERT_PATH}`); - expect(config).toContain(`xpack.security.http.ssl.certificate_authorities=${CA_CERT_PATH}`); + expect(config).toContain(`xpack.security.http.ssl.keystore.path=${ES_P12_PATH}`); + expect(config).toContain(`xpack.security.http.ssl.keystore.type=PKCS12`); + expect(config).toContain(`xpack.security.http.ssl.keystore.password=${ES_P12_PASSWORD}`); }); it(`doesn't setup SSL when disabled`, async () => { @@ -319,9 +319,9 @@ describe('#run()', () => { const config = extractConfigFiles.mock.calls[0][0]; expect(config).toContain('xpack.security.http.ssl.enabled=true'); - expect(config).toContain(`xpack.security.http.ssl.key=${ES_KEY_PATH}`); - expect(config).toContain(`xpack.security.http.ssl.certificate=${ES_CERT_PATH}`); - expect(config).toContain(`xpack.security.http.ssl.certificate_authorities=${CA_CERT_PATH}`); + expect(config).toContain(`xpack.security.http.ssl.keystore.path=${ES_P12_PATH}`); + expect(config).toContain(`xpack.security.http.ssl.keystore.type=PKCS12`); + expect(config).toContain(`xpack.security.http.ssl.keystore.password=${ES_P12_PASSWORD}`); }); it(`doesn't setup SSL when disabled`, async () => { diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index c3f3f2f477fdd..c806547595089 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -4500,6 +4500,14 @@ var certs_1 = __webpack_require__(422); exports.CA_CERT_PATH = certs_1.CA_CERT_PATH; exports.ES_KEY_PATH = certs_1.ES_KEY_PATH; exports.ES_CERT_PATH = certs_1.ES_CERT_PATH; +exports.ES_P12_PATH = certs_1.ES_P12_PATH; +exports.ES_P12_PASSWORD = certs_1.ES_P12_PASSWORD; +exports.ES_EMPTYPASSWORD_P12_PATH = certs_1.ES_EMPTYPASSWORD_P12_PATH; +exports.ES_NOPASSWORD_P12_PATH = certs_1.ES_NOPASSWORD_P12_PATH; +exports.KBN_KEY_PATH = certs_1.KBN_KEY_PATH; +exports.KBN_CERT_PATH = certs_1.KBN_CERT_PATH; +exports.KBN_P12_PATH = certs_1.KBN_P12_PATH; +exports.KBN_P12_PASSWORD = certs_1.KBN_P12_PASSWORD; var run_1 = __webpack_require__(423); exports.run = run_1.run; exports.createFailError = run_1.createFailError; @@ -36986,6 +36994,14 @@ const path_1 = __webpack_require__(16); exports.CA_CERT_PATH = path_1.resolve(__dirname, '../certs/ca.crt'); exports.ES_KEY_PATH = path_1.resolve(__dirname, '../certs/elasticsearch.key'); exports.ES_CERT_PATH = path_1.resolve(__dirname, '../certs/elasticsearch.crt'); +exports.ES_P12_PATH = path_1.resolve(__dirname, '../certs/elasticsearch.p12'); +exports.ES_P12_PASSWORD = 'storepass'; +exports.ES_EMPTYPASSWORD_P12_PATH = path_1.resolve(__dirname, '../certs/elasticsearch_emptypassword.p12'); +exports.ES_NOPASSWORD_P12_PATH = path_1.resolve(__dirname, '../certs/elasticsearch_nopassword.p12'); +exports.KBN_KEY_PATH = path_1.resolve(__dirname, '../certs/kibana.key'); +exports.KBN_CERT_PATH = path_1.resolve(__dirname, '../certs/kibana.crt'); +exports.KBN_P12_PATH = path_1.resolve(__dirname, '../certs/kibana.p12'); +exports.KBN_P12_PASSWORD = 'storepass'; /***/ }), diff --git a/renovate.json5 b/renovate.json5 index a5983283a9e85..560403046b0a5 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -625,6 +625,14 @@ '@types/node-fetch', ], }, + { + groupSlug: 'node-forge', + groupName: 'node-forge related packages', + packageNames: [ + 'node-forge', + '@types/node-forge', + ], + }, { groupSlug: 'nodemailer', groupName: 'nodemailer related packages', diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 6b13d0dc32d3f..9cf5691b88399 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -102,6 +102,8 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { } ensureNotDefined('server.ssl.certificate'); ensureNotDefined('server.ssl.key'); + ensureNotDefined('server.ssl.keystore.path'); + ensureNotDefined('server.ssl.truststore.path'); ensureNotDefined('elasticsearch.ssl.certificateAuthorities'); const elasticsearchHosts = ( @@ -119,6 +121,8 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { }); set('server.ssl.enabled', true); + // TODO: change this cert/key to KBN_CERT_PATH and KBN_KEY_PATH from '@kbn/dev-utils'; will require some work to avoid breaking + // functional tests. Once that is done, the existing test cert/key at DEV_SSL_CERT_PATH and DEV_SSL_KEY_PATH can be deleted. set('server.ssl.certificate', DEV_SSL_CERT_PATH); set('server.ssl.key', DEV_SSL_KEY_PATH); set('elasticsearch.hosts', elasticsearchHosts); diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts index b40dbdc1b6651..7851522ec899f 100644 --- a/src/core/server/config/deprecation/core_deprecations.test.ts +++ b/src/core/server/config/deprecation/core_deprecations.test.ts @@ -208,4 +208,35 @@ describe('core deprecations', () => { ).toEqual([`worker-src blob:`]); }); }); + + describe('elasticsearchUsernameDeprecation', () => { + it('logs a warning if elasticsearch.username is set to "elastic"', () => { + const { messages } = applyCoreDeprecations({ + elasticsearch: { + username: 'elastic', + }, + }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting elasticsearch.username to \\"elastic\\" is deprecated. You should use the \\"kibana\\" user instead.", + ] + `); + }); + + it('does not log a warning if elasticsearch.username is set to something besides "elastic"', () => { + const { messages } = applyCoreDeprecations({ + elasticsearch: { + username: 'otheruser', + }, + }); + expect(messages).toHaveLength(0); + }); + + it('does not log a warning if elasticsearch.username is unset', () => { + const { messages } = applyCoreDeprecations({ + elasticsearch: {}, + }); + expect(messages).toHaveLength(0); + }); + }); }); diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index 36fe95e05cb53..e3b66414ee163 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -91,6 +91,16 @@ const cspRulesDeprecation: ConfigDeprecation = (settings, fromPath, log) => { return settings; }; +const elasticsearchUsernameDeprecation: ConfigDeprecation = (settings, _fromPath, log) => { + const username: string | undefined = get(settings, 'elasticsearch.username'); + if (username === 'elastic') { + log( + `Setting elasticsearch.username to "elastic" is deprecated. You should use the "kibana" user instead.` + ); + } + return settings; +}; + export const coreDeprecationProvider: ConfigDeprecationProvider = ({ unusedFromRoot, renameFromRoot, @@ -110,4 +120,5 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({ dataPathDeprecation, rewriteBasePathDeprecation, cspRulesDeprecation, + elasticsearchUsernameDeprecation, ]; diff --git a/src/core/server/elasticsearch/elasticsearch_client_config.test.ts b/src/core/server/elasticsearch/elasticsearch_client_config.test.ts index 64fb41cb3e4e5..20c10459e0e8a 100644 --- a/src/core/server/elasticsearch/elasticsearch_client_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_client_config.test.ts @@ -17,8 +17,6 @@ * under the License. */ -import { mockReadFileSync } from './elasticsearch_client_config.test.mocks'; - import { duration } from 'moment'; import { loggingServiceMock } from '../logging/logging_service.mock'; import { @@ -66,8 +64,6 @@ Object { }); test('parses fully specified config', () => { - mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); - const elasticsearchConfig: ElasticsearchClientConfig = { apiVersion: 'v7.0.0', customHeaders: { xsrf: 'something' }, @@ -87,9 +83,9 @@ test('parses fully specified config', () => { sniffInterval: 11223344, ssl: { verificationMode: 'certificate', - certificateAuthorities: ['ca-path-1', 'ca-path-2'], - certificate: 'certificate-path', - key: 'key-path', + certificateAuthorities: ['content-of-ca-path-1', 'content-of-ca-path-2'], + certificate: 'content-of-certificate-path', + key: 'content-of-key-path', keyPassphrase: 'key-pass', alwaysPresentCertificate: true, }, @@ -497,6 +493,7 @@ Object { "sniffOnConnectionFault": true, "sniffOnStart": true, "ssl": Object { + "ca": undefined, "rejectUnauthorized": false, }, } @@ -541,6 +538,7 @@ Object { "sniffOnConnectionFault": true, "sniffOnStart": true, "ssl": Object { + "ca": undefined, "checkServerIdentity": [Function], "rejectUnauthorized": true, }, @@ -581,6 +579,7 @@ Object { "sniffOnConnectionFault": true, "sniffOnStart": true, "ssl": Object { + "ca": undefined, "rejectUnauthorized": true, }, } @@ -606,8 +605,6 @@ Object { }); test('#ignoreCertAndKey = true', () => { - mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); - expect( parseElasticsearchClientConfig( { @@ -620,9 +617,9 @@ Object { requestHeadersWhitelist: [], ssl: { verificationMode: 'certificate', - certificateAuthorities: ['ca-path'], - certificate: 'certificate-path', - key: 'key-path', + certificateAuthorities: ['content-of-ca-path'], + certificate: 'content-of-certificate-path', + key: 'content-of-key-path', keyPassphrase: 'key-pass', alwaysPresentCertificate: true, }, diff --git a/src/core/server/elasticsearch/elasticsearch_client_config.ts b/src/core/server/elasticsearch/elasticsearch_client_config.ts index dcc09f711abbe..287d835c40351 100644 --- a/src/core/server/elasticsearch/elasticsearch_client_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_client_config.ts @@ -18,7 +18,6 @@ */ import { ConfigOptions } from 'elasticsearch'; -import { readFileSync } from 'fs'; import { cloneDeep } from 'lodash'; import { Duration } from 'moment'; import { checkServerIdentity } from 'tls'; @@ -165,18 +164,12 @@ export function parseElasticsearchClientConfig( throw new Error(`Unknown ssl verificationMode: ${verificationMode}`); } - const readFile = (file: string) => readFileSync(file, 'utf8'); - if ( - config.ssl.certificateAuthorities !== undefined && - config.ssl.certificateAuthorities.length > 0 - ) { - esClientConfig.ssl.ca = config.ssl.certificateAuthorities.map(readFile); - } + esClientConfig.ssl.ca = config.ssl.certificateAuthorities; // Add client certificate and key if required by elasticsearch if (!ignoreCertAndKey && config.ssl.certificate && config.ssl.key) { - esClientConfig.ssl.cert = readFile(config.ssl.certificate); - esClientConfig.ssl.key = readFile(config.ssl.key); + esClientConfig.ssl.cert = config.ssl.certificate; + esClientConfig.ssl.key = config.ssl.key; esClientConfig.ssl.passphrase = config.ssl.keyPassphrase; } diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.mocks.ts b/src/core/server/elasticsearch/elasticsearch_config.test.mocks.ts new file mode 100644 index 0000000000000..d908fdbfd2e80 --- /dev/null +++ b/src/core/server/elasticsearch/elasticsearch_config.test.mocks.ts @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const mockReadFileSync = jest.fn(); +jest.mock('fs', () => ({ readFileSync: mockReadFileSync })); + +export const mockReadPkcs12Keystore = jest.fn(); +export const mockReadPkcs12Truststore = jest.fn(); +jest.mock('../../utils', () => ({ + readPkcs12Keystore: mockReadPkcs12Keystore, + readPkcs12Truststore: mockReadPkcs12Truststore, +})); diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.ts b/src/core/server/elasticsearch/elasticsearch_config.test.ts index 1d919639abe5f..c0db7369b4b99 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.ts @@ -17,10 +17,25 @@ * under the License. */ -import { ElasticsearchConfig, config } from './elasticsearch_config'; +import { + mockReadFileSync, + mockReadPkcs12Keystore, + mockReadPkcs12Truststore, +} from './elasticsearch_config.test.mocks'; + +import { ElasticsearchConfig, config, ElasticsearchConfigType } from './elasticsearch_config'; +import { loggingServiceMock } from '../mocks'; +import { Logger } from '../logging'; + +const createElasticsearchConfig = (rawConfig: ElasticsearchConfigType, log?: Logger) => { + if (!log) { + log = loggingServiceMock.create().get('config'); + } + return new ElasticsearchConfig(rawConfig, log); +}; test('set correct defaults', () => { - const configValue = new ElasticsearchConfig(config.schema.validate({})); + const configValue = createElasticsearchConfig(config.schema.validate({})); expect(configValue).toMatchInlineSnapshot(` ElasticsearchConfig { "apiVersion": "master", @@ -43,7 +58,10 @@ test('set correct defaults', () => { "sniffOnStart": false, "ssl": Object { "alwaysPresentCertificate": false, + "certificate": undefined, "certificateAuthorities": undefined, + "key": undefined, + "keyPassphrase": undefined, "verificationMode": "full", }, "username": undefined, @@ -52,17 +70,17 @@ test('set correct defaults', () => { }); test('#hosts accepts both string and array of strings', () => { - let configValue = new ElasticsearchConfig( + let configValue = createElasticsearchConfig( config.schema.validate({ hosts: 'http://some.host:1234' }) ); expect(configValue.hosts).toEqual(['http://some.host:1234']); - configValue = new ElasticsearchConfig( + configValue = createElasticsearchConfig( config.schema.validate({ hosts: ['http://some.host:1234'] }) ); expect(configValue.hosts).toEqual(['http://some.host:1234']); - configValue = new ElasticsearchConfig( + configValue = createElasticsearchConfig( config.schema.validate({ hosts: ['http://some.host:1234', 'https://some.another.host'], }) @@ -71,17 +89,17 @@ test('#hosts accepts both string and array of strings', () => { }); test('#requestHeadersWhitelist accepts both string and array of strings', () => { - let configValue = new ElasticsearchConfig( + let configValue = createElasticsearchConfig( config.schema.validate({ requestHeadersWhitelist: 'token' }) ); expect(configValue.requestHeadersWhitelist).toEqual(['token']); - configValue = new ElasticsearchConfig( + configValue = createElasticsearchConfig( config.schema.validate({ requestHeadersWhitelist: ['token'] }) ); expect(configValue.requestHeadersWhitelist).toEqual(['token']); - configValue = new ElasticsearchConfig( + configValue = createElasticsearchConfig( config.schema.validate({ requestHeadersWhitelist: ['token', 'X-Forwarded-Proto'], }) @@ -89,23 +107,216 @@ test('#requestHeadersWhitelist accepts both string and array of strings', () => expect(configValue.requestHeadersWhitelist).toEqual(['token', 'X-Forwarded-Proto']); }); -test('#ssl.certificateAuthorities accepts both string and array of strings', () => { - let configValue = new ElasticsearchConfig( - config.schema.validate({ ssl: { certificateAuthorities: 'some-path' } }) - ); - expect(configValue.ssl.certificateAuthorities).toEqual(['some-path']); +describe('reads files', () => { + beforeEach(() => { + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + mockReadPkcs12Keystore.mockReset(); + mockReadPkcs12Keystore.mockImplementation((path: string) => ({ + key: `content-of-${path}.key`, + cert: `content-of-${path}.cert`, + ca: [`content-of-${path}.ca`], + })); + mockReadPkcs12Truststore.mockReset(); + mockReadPkcs12Truststore.mockImplementation((path: string) => [`content-of-${path}`]); + }); - configValue = new ElasticsearchConfig( - config.schema.validate({ ssl: { certificateAuthorities: ['some-path'] } }) - ); - expect(configValue.ssl.certificateAuthorities).toEqual(['some-path']); + it('reads certificate authorities when ssl.keystore.path is specified', () => { + const configValue = createElasticsearchConfig( + config.schema.validate({ ssl: { keystore: { path: 'some-path' } } }) + ); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path.ca']); + }); - configValue = new ElasticsearchConfig( - config.schema.validate({ - ssl: { certificateAuthorities: ['some-path', 'another-path'] }, - }) - ); - expect(configValue.ssl.certificateAuthorities).toEqual(['some-path', 'another-path']); + it('reads certificate authorities when ssl.truststore.path is specified', () => { + const configValue = createElasticsearchConfig( + config.schema.validate({ ssl: { truststore: { path: 'some-path' } } }) + ); + expect(mockReadPkcs12Truststore).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path']); + }); + + it('reads certificate authorities when ssl.certificateAuthorities is specified', () => { + let configValue = createElasticsearchConfig( + config.schema.validate({ ssl: { certificateAuthorities: 'some-path' } }) + ); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path']); + + mockReadFileSync.mockClear(); + configValue = createElasticsearchConfig( + config.schema.validate({ ssl: { certificateAuthorities: ['some-path'] } }) + ); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path']); + + mockReadFileSync.mockClear(); + configValue = createElasticsearchConfig( + config.schema.validate({ + ssl: { certificateAuthorities: ['some-path', 'another-path'] }, + }) + ); + expect(mockReadFileSync).toHaveBeenCalledTimes(2); + expect(configValue.ssl.certificateAuthorities).toEqual([ + 'content-of-some-path', + 'content-of-another-path', + ]); + }); + + it('reads certificate authorities when ssl.keystore.path, ssl.truststore.path, and ssl.certificateAuthorities are specified', () => { + const configValue = createElasticsearchConfig( + config.schema.validate({ + ssl: { + keystore: { path: 'some-path' }, + truststore: { path: 'another-path' }, + certificateAuthorities: 'yet-another-path', + }, + }) + ); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(mockReadPkcs12Truststore).toHaveBeenCalledTimes(1); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificateAuthorities).toEqual([ + 'content-of-some-path.ca', + 'content-of-another-path', + 'content-of-yet-another-path', + ]); + }); + + it('reads a private key and certificate when ssl.keystore.path is specified', () => { + const configValue = createElasticsearchConfig( + config.schema.validate({ ssl: { keystore: { path: 'some-path' } } }) + ); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(configValue.ssl.key).toEqual('content-of-some-path.key'); + expect(configValue.ssl.certificate).toEqual('content-of-some-path.cert'); + }); + + it('reads a private key when ssl.key is specified', () => { + const configValue = createElasticsearchConfig( + config.schema.validate({ ssl: { key: 'some-path' } }) + ); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.ssl.key).toEqual('content-of-some-path'); + }); + + it('reads a certificate when ssl.certificate is specified', () => { + const configValue = createElasticsearchConfig( + config.schema.validate({ ssl: { certificate: 'some-path' } }) + ); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificate).toEqual('content-of-some-path'); + }); +}); + +describe('throws when config is invalid', () => { + beforeAll(() => { + const realFs = jest.requireActual('fs'); + mockReadFileSync.mockImplementation((path: string) => realFs.readFileSync(path)); + const utils = jest.requireActual('../../utils'); + mockReadPkcs12Keystore.mockImplementation((path: string, password?: string) => + utils.readPkcs12Keystore(path, password) + ); + mockReadPkcs12Truststore.mockImplementation((path: string, password?: string) => + utils.readPkcs12Truststore(path, password) + ); + }); + + it('throws if key is invalid', () => { + const value = { ssl: { key: '/invalid/key' } }; + expect(() => + createElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/key'"` + ); + }); + + it('throws if certificate is invalid', () => { + const value = { ssl: { certificate: '/invalid/cert' } }; + expect(() => + createElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/cert'"` + ); + }); + + it('throws if certificateAuthorities is invalid', () => { + const value = { ssl: { certificateAuthorities: '/invalid/ca' } }; + expect(() => + createElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot(`"ENOENT: no such file or directory, open '/invalid/ca'"`); + }); + + it('throws if keystore path is invalid', () => { + const value = { ssl: { keystore: { path: '/invalid/keystore' } } }; + expect(() => + createElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/keystore'"` + ); + }); + + it('throws if keystore does not contain a key or certificate', () => { + mockReadPkcs12Keystore.mockReturnValueOnce({}); + const value = { ssl: { keystore: { path: 'some-path' } } }; + expect(() => + createElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot( + `"Did not find key or certificate in Elasticsearch keystore."` + ); + }); + + it('throws if truststore path is invalid', () => { + const value = { ssl: { keystore: { path: '/invalid/truststore' } } }; + expect(() => + createElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/truststore'"` + ); + }); + + it('throws if key and keystore.path are both specified', () => { + const value = { ssl: { key: 'foo', keystore: { path: 'bar' } } }; + expect(() => config.schema.validate(value)).toThrowErrorMatchingInlineSnapshot( + `"[ssl]: cannot use [key] when [keystore.path] is specified"` + ); + }); + + it('throws if certificate and keystore.path are both specified', () => { + const value = { ssl: { certificate: 'foo', keystore: { path: 'bar' } } }; + expect(() => config.schema.validate(value)).toThrowErrorMatchingInlineSnapshot( + `"[ssl]: cannot use [certificate] when [keystore.path] is specified"` + ); + }); +}); + +describe('logs warnings', () => { + let logger: ReturnType; + let log: Logger; + + beforeAll(() => { + mockReadFileSync.mockResolvedValue('foo'); + }); + + beforeEach(() => { + logger = loggingServiceMock.create(); + log = logger.get('config'); + }); + + it('warns if ssl.key is set and ssl.certificate is not', () => { + createElasticsearchConfig(config.schema.validate({ ssl: { key: 'some-path' } }), log); + expect(loggingServiceMock.collect(logger).warn[0][0]).toMatchInlineSnapshot( + `"Detected a key without a certificate; mutual TLS authentication is disabled."` + ); + }); + + it('warns if ssl.certificate is set and ssl.key is not', () => { + createElasticsearchConfig(config.schema.validate({ ssl: { certificate: 'some-path' } }), log); + expect(loggingServiceMock.collect(logger).warn[0][0]).toMatchInlineSnapshot( + `"Detected a certificate without a key; mutual TLS authentication is disabled."` + ); + }); }); test('#username throws if equal to "elastic", only while running from source', () => { diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index 8d92b12ae4a77..815005f65c6e7 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -19,6 +19,8 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { Duration } from 'moment'; +import { readFileSync } from 'fs'; +import { readPkcs12Keystore, readPkcs12Truststore } from '../../utils'; import { Logger } from '../logging'; const hostURISchema = schema.uri({ scheme: ['http', 'https'] }); @@ -67,19 +69,39 @@ export const config = { pingTimeout: schema.duration({ defaultValue: schema.siblingRef('requestTimeout') }), startupTimeout: schema.duration({ defaultValue: '5s' }), logQueries: schema.boolean({ defaultValue: false }), - ssl: schema.object({ - verificationMode: schema.oneOf( - [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], - { defaultValue: 'full' } - ), - certificateAuthorities: schema.maybe( - schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })]) - ), - certificate: schema.maybe(schema.string()), - key: schema.maybe(schema.string()), - keyPassphrase: schema.maybe(schema.string()), - alwaysPresentCertificate: schema.boolean({ defaultValue: false }), - }), + ssl: schema.object( + { + verificationMode: schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ), + certificateAuthorities: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })]) + ), + certificate: schema.maybe(schema.string()), + key: schema.maybe(schema.string()), + keyPassphrase: schema.maybe(schema.string()), + keystore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), + truststore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), + alwaysPresentCertificate: schema.boolean({ defaultValue: false }), + }, + { + validate: rawConfig => { + if (rawConfig.key && rawConfig.keystore.path) { + return 'cannot use [key] when [keystore.path] is specified'; + } + if (rawConfig.certificate && rawConfig.keystore.path) { + return 'cannot use [certificate] when [keystore.path] is specified'; + } + }, + } + ), apiVersion: schema.string({ defaultValue: DEFAULT_API_VERSION }), healthCheck: schema.object({ delay: schema.duration({ defaultValue: 2500 }) }), ignoreVersionMismatch: schema.boolean({ defaultValue: false }), @@ -173,7 +195,7 @@ export class ElasticsearchConfig { */ public readonly ssl: Pick< SslConfigSchema, - Exclude + Exclude > & { certificateAuthorities?: string[] }; /** @@ -183,7 +205,7 @@ export class ElasticsearchConfig { */ public readonly customHeaders: ElasticsearchConfigType['customHeaders']; - constructor(rawConfig: ElasticsearchConfigType, log?: Logger) { + constructor(rawConfig: ElasticsearchConfigType, log: Logger) { this.ignoreVersionMismatch = rawConfig.ignoreVersionMismatch; this.apiVersion = rawConfig.apiVersion; this.logQueries = rawConfig.logQueries; @@ -202,24 +224,87 @@ export class ElasticsearchConfig { this.password = rawConfig.password; this.customHeaders = rawConfig.customHeaders; - const certificateAuthorities = Array.isArray(rawConfig.ssl.certificateAuthorities) - ? rawConfig.ssl.certificateAuthorities - : typeof rawConfig.ssl.certificateAuthorities === 'string' - ? [rawConfig.ssl.certificateAuthorities] - : undefined; + const { alwaysPresentCertificate, verificationMode } = rawConfig.ssl; + const { key, keyPassphrase, certificate, certificateAuthorities } = readKeyAndCerts(rawConfig); + + if (key && !certificate) { + log.warn(`Detected a key without a certificate; mutual TLS authentication is disabled.`); + } else if (certificate && !key) { + log.warn(`Detected a certificate without a key; mutual TLS authentication is disabled.`); + } this.ssl = { - ...rawConfig.ssl, + alwaysPresentCertificate, + key, + keyPassphrase, + certificate, certificateAuthorities, + verificationMode, }; + } +} + +const readKeyAndCerts = (rawConfig: ElasticsearchConfigType) => { + let key: string | undefined; + let keyPassphrase: string | undefined; + let certificate: string | undefined; + let certificateAuthorities: string[] | undefined; - if (this.username === 'elastic' && log !== undefined) { - // logger is optional / not used during tests - // TODO: logger can be removed when issue #40255 is resolved to support deprecations in NP config service - log.warn( - `Setting the elasticsearch username to "elastic" is deprecated. You should use the "kibana" user instead.`, - { tags: ['deprecation'] } - ); + const addCAs = (ca: string[] | undefined) => { + if (ca && ca.length) { + certificateAuthorities = [...(certificateAuthorities || []), ...ca]; + } + }; + + if (rawConfig.ssl.keystore?.path) { + const keystore = readPkcs12Keystore( + rawConfig.ssl.keystore.path, + rawConfig.ssl.keystore.password + ); + if (!keystore.key && !keystore.cert) { + throw new Error(`Did not find key or certificate in Elasticsearch keystore.`); + } + key = keystore.key; + certificate = keystore.cert; + addCAs(keystore.ca); + } else { + if (rawConfig.ssl.key) { + key = readFile(rawConfig.ssl.key); + keyPassphrase = rawConfig.ssl.keyPassphrase; + } + if (rawConfig.ssl.certificate) { + certificate = readFile(rawConfig.ssl.certificate); } } -} + + if (rawConfig.ssl.truststore?.path) { + const ca = readPkcs12Truststore( + rawConfig.ssl.truststore.path, + rawConfig.ssl.truststore.password + ); + addCAs(ca); + } + + const ca = rawConfig.ssl.certificateAuthorities; + if (ca) { + const parsed: string[] = []; + const paths = Array.isArray(ca) ? ca : [ca]; + if (paths.length > 0) { + for (const path of paths) { + parsed.push(readFile(path)); + } + addCAs(parsed); + } + } + + return { + key, + keyPassphrase, + certificate, + certificateAuthorities, + }; +}; + +const readFile = (file: string) => { + return readFileSync(file, 'utf8'); +}; diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index aefc8b1fdf047..9f694ac1c46da 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -196,7 +196,11 @@ Object { undefined, ], "ssl": Object { + "alwaysPresentCertificate": undefined, + "certificate": undefined, "certificateAuthorities": undefined, + "key": undefined, + "keyPassphrase": undefined, "verificationMode": "none", }, } diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap index 8856eb95ba722..28933a035c870 100644 --- a/src/core/server/http/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -67,10 +67,12 @@ Object { ], "clientAuthentication": "none", "enabled": false, + "keystore": Object {}, "supportedProtocols": Array [ "TLSv1.1", "TLSv1.2", ], + "truststore": Object {}, }, "xsrf": Object { "disableProtection": false, @@ -87,24 +89,6 @@ exports[`throws if basepath is not specified, but rewriteBasePath is set 1`] = ` exports[`throws if invalid hostname 1`] = `"[host]: value is [asdf$%^] but it must be a valid hostname (see RFC 1123)."`; -exports[`with TLS should accept known protocols\` 1`] = ` -"[ssl.supportedProtocols.0]: types that failed validation: -- [ssl.supportedProtocols.0.0]: expected value to equal [TLSv1] but got [SOMEv100500] -- [ssl.supportedProtocols.0.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] -- [ssl.supportedProtocols.0.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" -`; - -exports[`with TLS should accept known protocols\` 2`] = ` -"[ssl.supportedProtocols.3]: types that failed validation: -- [ssl.supportedProtocols.3.0]: expected value to equal [TLSv1] but got [SOMEv100500] -- [ssl.supportedProtocols.3.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] -- [ssl.supportedProtocols.3.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" -`; - -exports[`with TLS throws if TLS is enabled but \`certificate\` is not specified 1`] = `"[ssl]: must specify [certificate] and [key] when ssl is enabled"`; - -exports[`with TLS throws if TLS is enabled but \`key\` is not specified 1`] = `"[ssl]: must specify [certificate] and [key] when ssl is enabled"`; - exports[`with TLS throws if TLS is enabled but \`redirectHttpFromPort\` is equal to \`port\` 1`] = `"Kibana does not accept http traffic to [port] when ssl is enabled (only https is allowed), so [ssl.redirectHttpFromPort] cannot be configured to the same value. Both are [1234]."`; exports[`with compression accepts valid referrer whitelist 1`] = ` diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 3dc5fa48bc366..7ac707b0f3d83 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -18,7 +18,7 @@ */ import uuid from 'uuid'; -import { config, HttpConfig } from '.'; +import { config } from '.'; const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost']; const invalidHostname = 'asdf$%^'; @@ -108,28 +108,6 @@ test('throws if xsrf.whitelist element does not start with a slash', () => { }); describe('with TLS', () => { - test('throws if TLS is enabled but `key` is not specified', () => { - const httpSchema = config.schema; - const obj = { - ssl: { - certificate: '/path/to/certificate', - enabled: true, - }, - }; - expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); - }); - - test('throws if TLS is enabled but `certificate` is not specified', () => { - const httpSchema = config.schema; - const obj = { - ssl: { - enabled: true, - key: '/path/to/key', - }, - }; - expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); - }); - test('throws if TLS is enabled but `redirectHttpFromPort` is equal to `port`', () => { const httpSchema = config.schema; const obj = { @@ -143,189 +121,16 @@ describe('with TLS', () => { }; expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); }); +}); - test('throws if TLS is not enabled but `clientAuthentication` is `optional`', () => { - const httpSchema = config.schema; - const obj = { - port: 1234, - ssl: { - enabled: false, - clientAuthentication: 'optional', - }, - }; - expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( - `"[ssl]: must enable ssl to use [clientAuthentication]"` - ); - }); - - test('throws if TLS is not enabled but `clientAuthentication` is `required`', () => { - const httpSchema = config.schema; - const obj = { - port: 1234, - ssl: { - enabled: false, - clientAuthentication: 'required', - }, - }; - expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( - `"[ssl]: must enable ssl to use [clientAuthentication]"` - ); - }); - - test('can specify `none` for [clientAuthentication] if ssl is not enabled', () => { - const obj = { - ssl: { - enabled: false, - clientAuthentication: 'none', - }, - }; - - const configValue = config.schema.validate(obj); - expect(configValue.ssl.clientAuthentication).toBe('none'); - }); - - test('can specify single `certificateAuthority` as a string', () => { - const obj = { - ssl: { - certificate: '/path/to/certificate', - certificateAuthorities: '/authority/', - enabled: true, - key: '/path/to/key', - }, - }; - - const configValue = config.schema.validate(obj); - expect(configValue.ssl.certificateAuthorities).toBe('/authority/'); - }); - - test('can specify socket timeouts', () => { - const obj = { - keepaliveTimeout: 1e5, - socketTimeout: 5e5, - }; - const { keepaliveTimeout, socketTimeout } = config.schema.validate(obj); - expect(keepaliveTimeout).toBe(1e5); - expect(socketTimeout).toBe(5e5); - }); - - test('can specify several `certificateAuthorities`', () => { - const obj = { - ssl: { - certificate: '/path/to/certificate', - certificateAuthorities: ['/authority/1', '/authority/2'], - enabled: true, - key: '/path/to/key', - }, - }; - - const configValue = config.schema.validate(obj); - expect(configValue.ssl.certificateAuthorities).toEqual(['/authority/1', '/authority/2']); - }); - - test('accepts known protocols`', () => { - const httpSchema = config.schema; - const singleKnownProtocol = { - ssl: { - certificate: '/path/to/certificate', - enabled: true, - key: '/path/to/key', - supportedProtocols: ['TLSv1'], - }, - }; - - const allKnownProtocols = { - ssl: { - certificate: '/path/to/certificate', - enabled: true, - key: '/path/to/key', - supportedProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2'], - }, - }; - - const singleKnownProtocolConfig = httpSchema.validate(singleKnownProtocol); - expect(singleKnownProtocolConfig.ssl.supportedProtocols).toEqual(['TLSv1']); - - const allKnownProtocolsConfig = httpSchema.validate(allKnownProtocols); - expect(allKnownProtocolsConfig.ssl.supportedProtocols).toEqual(['TLSv1', 'TLSv1.1', 'TLSv1.2']); - }); - - test('should accept known protocols`', () => { - const httpSchema = config.schema; - - const singleUnknownProtocol = { - ssl: { - certificate: '/path/to/certificate', - enabled: true, - key: '/path/to/key', - supportedProtocols: ['SOMEv100500'], - }, - }; - - const allKnownWithOneUnknownProtocols = { - ssl: { - certificate: '/path/to/certificate', - enabled: true, - key: '/path/to/key', - supportedProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'SOMEv100500'], - }, - }; - - expect(() => httpSchema.validate(singleUnknownProtocol)).toThrowErrorMatchingSnapshot(); - expect(() => - httpSchema.validate(allKnownWithOneUnknownProtocols) - ).toThrowErrorMatchingSnapshot(); - }); - - test('HttpConfig instance should properly interpret `none` client authentication', () => { - const httpConfig = new HttpConfig( - config.schema.validate({ - ssl: { - enabled: true, - key: 'some-key-path', - certificate: 'some-certificate-path', - clientAuthentication: 'none', - }, - }), - {} as any - ); - - expect(httpConfig.ssl.requestCert).toBe(false); - expect(httpConfig.ssl.rejectUnauthorized).toBe(false); - }); - - test('HttpConfig instance should properly interpret `optional` client authentication', () => { - const httpConfig = new HttpConfig( - config.schema.validate({ - ssl: { - enabled: true, - key: 'some-key-path', - certificate: 'some-certificate-path', - clientAuthentication: 'optional', - }, - }), - {} as any - ); - - expect(httpConfig.ssl.requestCert).toBe(true); - expect(httpConfig.ssl.rejectUnauthorized).toBe(false); - }); - - test('HttpConfig instance should properly interpret `required` client authentication', () => { - const httpConfig = new HttpConfig( - config.schema.validate({ - ssl: { - enabled: true, - key: 'some-key-path', - certificate: 'some-certificate-path', - clientAuthentication: 'required', - }, - }), - {} as any - ); - - expect(httpConfig.ssl.requestCert).toBe(true); - expect(httpConfig.ssl.rejectUnauthorized).toBe(true); - }); +test('can specify socket timeouts', () => { + const obj = { + keepaliveTimeout: 1e5, + socketTimeout: 5e5, + }; + const { keepaliveTimeout, socketTimeout } = config.schema.validate(obj); + expect(keepaliveTimeout).toBe(1e5); + expect(socketTimeout).toBe(5e5); }); describe('with compression', () => { diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index df357aeaf2731..df7b4b5af4267 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -18,11 +18,7 @@ */ import { Server } from 'http'; - -jest.mock('fs', () => ({ - readFileSync: jest.fn(), -})); - +import { readFileSync } from 'fs'; import supertest from 'supertest'; import { ByteSizeValue, schema } from '@kbn/config-schema'; @@ -39,6 +35,7 @@ import { loggingServiceMock } from '../logging/logging_service.mock'; import { HttpServer } from './http_server'; import { Readable } from 'stream'; import { RequestHandlerContext } from 'kibana/server'; +import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; const cookieOptions = { name: 'sid', @@ -55,6 +52,14 @@ const loggingService = loggingServiceMock.create(); const logger = loggingService.get(); const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); +let certificate: string; +let key: string; + +beforeAll(() => { + certificate = readFileSync(KBN_CERT_PATH, 'utf8'); + key = readFileSync(KBN_KEY_PATH, 'utf8'); +}); + beforeEach(() => { config = { host: '127.0.0.1', @@ -68,10 +73,10 @@ beforeEach(() => { ...config, ssl: { enabled: true, - certificate: '/certificate', + certificate, cipherSuites: ['cipherSuite'], getSecureOptions: () => 0, - key: '/key', + key, redirectHttpFromPort: config.port + 1, }, } as HttpConfig; diff --git a/src/core/server/http/http_tools.ts b/src/core/server/http/http_tools.ts index 22468a5b252f4..747fd5a10b168 100644 --- a/src/core/server/http/http_tools.ts +++ b/src/core/server/http/http_tools.ts @@ -17,7 +17,6 @@ * under the License. */ -import { readFileSync } from 'fs'; import { Lifecycle, Request, ResponseToolkit, Server, ServerOptions, Util } from 'hapi'; import Hoek from 'hoek'; import { ServerOptions as TLSOptions } from 'https'; @@ -66,14 +65,12 @@ export function getServerOptions(config: HttpConfig, { configureTLS = true } = { // TODO: Hapi types have a typo in `tls` property type definition: `https.RequestOptions` is used instead of // `https.ServerOptions`, and `honorCipherOrder` isn't presented in `https.RequestOptions`. const tlsOptions: TLSOptions = { - ca: - config.ssl.certificateAuthorities && - config.ssl.certificateAuthorities.map(caFilePath => readFileSync(caFilePath)), - cert: readFileSync(ssl.certificate!), + ca: ssl.certificateAuthorities, + cert: ssl.certificate, ciphers: config.ssl.cipherSuites.join(':'), // We use the server's cipher order rather than the client's to prevent the BEAST attack. honorCipherOrder: true, - key: readFileSync(ssl.key!), + key: ssl.key, passphrase: ssl.keyPassphrase, secureOptions: ssl.getSecureOptions(), requestCert: ssl.requestCert, diff --git a/src/core/server/http/ssl_config.test.mocks.ts b/src/core/server/http/ssl_config.test.mocks.ts new file mode 100644 index 0000000000000..ab98c3a27920c --- /dev/null +++ b/src/core/server/http/ssl_config.test.mocks.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const mockReadFileSync = jest.fn(); +jest.mock('fs', () => { + return { readFileSync: mockReadFileSync }; +}); + +export const mockReadPkcs12Keystore = jest.fn(); +export const mockReadPkcs12Truststore = jest.fn(); +jest.mock('../../utils', () => ({ + readPkcs12Keystore: mockReadPkcs12Keystore, + readPkcs12Truststore: mockReadPkcs12Truststore, +})); diff --git a/src/core/server/http/ssl_config.test.ts b/src/core/server/http/ssl_config.test.ts new file mode 100644 index 0000000000000..738f86f7a69eb --- /dev/null +++ b/src/core/server/http/ssl_config.test.ts @@ -0,0 +1,363 @@ +/* + * 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 { + mockReadFileSync, + mockReadPkcs12Keystore, + mockReadPkcs12Truststore, +} from './ssl_config.test.mocks'; + +import { sslSchema, SslConfig } from './ssl_config'; + +describe('#SslConfig', () => { + const createConfig = (obj: any) => new SslConfig(sslSchema.validate(obj)); + + beforeEach(() => { + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + mockReadPkcs12Keystore.mockReset(); + mockReadPkcs12Keystore.mockImplementation((path: string) => ({ + key: `content-of-${path}.key`, + cert: `content-of-${path}.cert`, + ca: [`content-of-${path}.ca`], + })); + mockReadPkcs12Truststore.mockReset(); + mockReadPkcs12Truststore.mockImplementation((path: string) => [`content-of-${path}`]); + }); + + describe('throws when config is invalid', () => { + beforeEach(() => { + const realFs = jest.requireActual('fs'); + mockReadFileSync.mockImplementation((path: string) => realFs.readFileSync(path)); + const utils = jest.requireActual('../../utils'); + mockReadPkcs12Keystore.mockImplementation((path: string, password?: string) => + utils.readPkcs12Keystore(path, password) + ); + mockReadPkcs12Truststore.mockImplementation((path: string, password?: string) => + utils.readPkcs12Truststore(path, password) + ); + }); + + test('throws if `key` is invalid', () => { + const obj = { key: '/invalid/key', certificate: '/valid/certificate' }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/key'"` + ); + }); + + test('throws if `certificate` is invalid', () => { + mockReadFileSync.mockImplementationOnce((path: string) => `content-of-${path}`); + const obj = { key: '/valid/key', certificate: '/invalid/certificate' }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/certificate'"` + ); + }); + + test('throws if `certificateAuthorities` is invalid', () => { + const obj = { certificateAuthorities: '/invalid/ca' }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/ca'"` + ); + }); + + test('throws if `keystore.path` is invalid', () => { + const obj = { keystore: { path: '/invalid/keystore' } }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/keystore'"` + ); + }); + + test('throws if `keystore.path` does not contain a private key', () => { + mockReadPkcs12Keystore.mockImplementation((path: string, password?: string) => ({ + key: undefined, + certificate: 'foo', + })); + const obj = { keystore: { path: 'some-path' } }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"Did not find private key in keystore at [keystore.path]."` + ); + }); + + test('throws if `keystore.path` does not contain a certificate', () => { + mockReadPkcs12Keystore.mockImplementation((path: string, password?: string) => ({ + key: 'foo', + certificate: undefined, + })); + const obj = { keystore: { path: 'some-path' } }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"Did not find certificate in keystore at [keystore.path]."` + ); + }); + + test('throws if `truststore.path` is invalid', () => { + const obj = { truststore: { path: '/invalid/truststore' } }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/truststore'"` + ); + }); + }); + + describe('reads files', () => { + it('reads certificate authorities when `keystore.path` is specified', () => { + const configValue = createConfig({ keystore: { path: 'some-path' } }); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(configValue.certificateAuthorities).toEqual(['content-of-some-path.ca']); + }); + + it('reads certificate authorities when `truststore.path` is specified', () => { + const configValue = createConfig({ truststore: { path: 'some-path' } }); + expect(mockReadPkcs12Truststore).toHaveBeenCalledTimes(1); + expect(configValue.certificateAuthorities).toEqual(['content-of-some-path']); + }); + + it('reads certificate authorities when `certificateAuthorities` is specified', () => { + let configValue = createConfig({ certificateAuthorities: 'some-path' }); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.certificateAuthorities).toEqual(['content-of-some-path']); + + mockReadFileSync.mockClear(); + configValue = createConfig({ certificateAuthorities: ['some-path'] }); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.certificateAuthorities).toEqual(['content-of-some-path']); + + mockReadFileSync.mockClear(); + configValue = createConfig({ certificateAuthorities: ['some-path', 'another-path'] }); + expect(mockReadFileSync).toHaveBeenCalledTimes(2); + expect(configValue.certificateAuthorities).toEqual([ + 'content-of-some-path', + 'content-of-another-path', + ]); + }); + + it('reads certificate authorities when `keystore.path`, `truststore.path`, and `certificateAuthorities` are specified', () => { + const configValue = createConfig({ + keystore: { path: 'some-path' }, + truststore: { path: 'another-path' }, + certificateAuthorities: 'yet-another-path', + }); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(mockReadPkcs12Truststore).toHaveBeenCalledTimes(1); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.certificateAuthorities).toEqual([ + 'content-of-some-path.ca', + 'content-of-another-path', + 'content-of-yet-another-path', + ]); + }); + + it('reads a private key and certificate when `keystore.path` is specified', () => { + const configValue = createConfig({ keystore: { path: 'some-path' } }); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(configValue.key).toEqual('content-of-some-path.key'); + expect(configValue.certificate).toEqual('content-of-some-path.cert'); + }); + + it('reads a private key and certificate when `key` and `certificate` are specified', () => { + const configValue = createConfig({ key: 'some-path', certificate: 'another-path' }); + expect(mockReadFileSync).toHaveBeenCalledTimes(2); + expect(configValue.key).toEqual('content-of-some-path'); + expect(configValue.certificate).toEqual('content-of-another-path'); + }); + }); +}); + +describe('#sslSchema', () => { + describe('throws when config is invalid', () => { + test('throws if both `key` and `keystore.path` are specified', () => { + const obj = { + key: '/path/to/key', + keystore: { + path: 'path/to/keystore', + }, + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"cannot use [key] when [keystore.path] is specified"` + ); + }); + + test('throws if both `certificate` and `keystore.path` are specified', () => { + const obj = { + certificate: '/path/to/certificate', + keystore: { + path: 'path/to/keystore', + }, + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"cannot use [certificate] when [keystore.path] is specified"` + ); + }); + + test('throws if TLS is enabled but `certificate` is specified and `key` is not', () => { + const obj = { + certificate: '/path/to/certificate', + enabled: true, + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"must specify [certificate] and [key] -- or [keystore.path] -- when ssl is enabled"` + ); + }); + + test('throws if TLS is enabled but `key` is specified and `certificate` is not', () => { + const obj = { + enabled: true, + key: '/path/to/key', + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"must specify [certificate] and [key] -- or [keystore.path] -- when ssl is enabled"` + ); + }); + + test('throws if TLS is enabled but `key`, `certificate`, and `keystore.path` are not specified', () => { + const obj = { + enabled: true, + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"must specify [certificate] and [key] -- or [keystore.path] -- when ssl is enabled"` + ); + }); + + test('throws if TLS is not enabled but `clientAuthentication` is `optional`', () => { + const obj = { + enabled: false, + clientAuthentication: 'optional', + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"must enable ssl to use [clientAuthentication]"` + ); + }); + + test('throws if TLS is not enabled but `clientAuthentication` is `required`', () => { + const obj = { + enabled: false, + clientAuthentication: 'required', + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"must enable ssl to use [clientAuthentication]"` + ); + }); + }); + + describe('#supportedProtocols', () => { + test('accepts known protocols`', () => { + const singleKnownProtocol = { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['TLSv1'], + }; + + const allKnownProtocols = { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2'], + }; + + const singleKnownProtocolConfig = sslSchema.validate(singleKnownProtocol); + expect(singleKnownProtocolConfig.supportedProtocols).toEqual(['TLSv1']); + + const allKnownProtocolsConfig = sslSchema.validate(allKnownProtocols); + expect(allKnownProtocolsConfig.supportedProtocols).toEqual(['TLSv1', 'TLSv1.1', 'TLSv1.2']); + }); + + test('rejects unknown protocols`', () => { + const singleUnknownProtocol = { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['SOMEv100500'], + }; + + const allKnownWithOneUnknownProtocols = { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'SOMEv100500'], + }; + + expect(() => sslSchema.validate(singleUnknownProtocol)).toThrowErrorMatchingInlineSnapshot(` +"[supportedProtocols.0]: types that failed validation: +- [supportedProtocols.0.0]: expected value to equal [TLSv1] but got [SOMEv100500] +- [supportedProtocols.0.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] +- [supportedProtocols.0.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" +`); + expect(() => sslSchema.validate(allKnownWithOneUnknownProtocols)) + .toThrowErrorMatchingInlineSnapshot(` +"[supportedProtocols.3]: types that failed validation: +- [supportedProtocols.3.0]: expected value to equal [TLSv1] but got [SOMEv100500] +- [supportedProtocols.3.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] +- [supportedProtocols.3.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" +`); + }); + }); + + describe('#clientAuthentication', () => { + test('can specify `none` client authentication when ssl is not enabled', () => { + const obj = { + enabled: false, + clientAuthentication: 'none', + }; + + const configValue = sslSchema.validate(obj); + expect(configValue.clientAuthentication).toBe('none'); + }); + + test('should properly interpret `none` client authentication when ssl is enabled', () => { + const sslConfig = new SslConfig( + sslSchema.validate({ + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + clientAuthentication: 'none', + }) + ); + + expect(sslConfig.requestCert).toBe(false); + expect(sslConfig.rejectUnauthorized).toBe(false); + }); + + test('should properly interpret `optional` client authentication when ssl is enabled', () => { + const sslConfig = new SslConfig( + sslSchema.validate({ + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + clientAuthentication: 'optional', + }) + ); + + expect(sslConfig.requestCert).toBe(true); + expect(sslConfig.rejectUnauthorized).toBe(false); + }); + + test('should properly interpret `required` client authentication when ssl is enabled', () => { + const sslConfig = new SslConfig( + sslSchema.validate({ + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + clientAuthentication: 'required', + }) + ); + + expect(sslConfig.requestCert).toBe(true); + expect(sslConfig.rejectUnauthorized).toBe(true); + }); + }); +}); diff --git a/src/core/server/http/ssl_config.ts b/src/core/server/http/ssl_config.ts index 55d6ebff93ce7..0096eeb092565 100644 --- a/src/core/server/http/ssl_config.ts +++ b/src/core/server/http/ssl_config.ts @@ -19,6 +19,8 @@ import { schema, TypeOf } from '@kbn/config-schema'; import crypto from 'crypto'; +import { readFileSync } from 'fs'; +import { readPkcs12Keystore, readPkcs12Truststore } from '../../utils'; // `crypto` type definitions doesn't currently include `crypto.constants`, see // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/fa5baf1733f49cf26228a4e509914572c1b74adf/types/node/v6/index.d.ts#L3412 @@ -44,6 +46,14 @@ export const sslSchema = schema.object( }), key: schema.maybe(schema.string()), keyPassphrase: schema.maybe(schema.string()), + keystore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), + truststore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), redirectHttpFromPort: schema.maybe(schema.number()), supportedProtocols: schema.arrayOf( schema.oneOf([schema.literal('TLSv1'), schema.literal('TLSv1.1'), schema.literal('TLSv1.2')]), @@ -56,8 +66,16 @@ export const sslSchema = schema.object( }, { validate: ssl => { - if (ssl.enabled && (!ssl.key || !ssl.certificate)) { - return 'must specify [certificate] and [key] when ssl is enabled'; + if (ssl.key && ssl.keystore.path) { + return 'cannot use [key] when [keystore.path] is specified'; + } + + if (ssl.certificate && ssl.keystore.path) { + return 'cannot use [certificate] when [keystore.path] is specified'; + } + + if (ssl.enabled && (!ssl.key || !ssl.certificate) && !ssl.keystore.path) { + return 'must specify [certificate] and [key] -- or [keystore.path] -- when ssl is enabled'; } if (!ssl.enabled && ssl.clientAuthentication !== 'none') { @@ -88,14 +106,49 @@ export class SslConfig { constructor(config: SslConfigType) { this.enabled = config.enabled; this.redirectHttpFromPort = config.redirectHttpFromPort; - this.key = config.key; - this.certificate = config.certificate; - this.certificateAuthorities = this.initCertificateAuthorities(config.certificateAuthorities); - this.keyPassphrase = config.keyPassphrase; this.cipherSuites = config.cipherSuites; this.supportedProtocols = config.supportedProtocols; this.requestCert = config.clientAuthentication !== 'none'; this.rejectUnauthorized = config.clientAuthentication === 'required'; + + const addCAs = (ca: string[] | undefined) => { + if (ca && ca.length) { + this.certificateAuthorities = [...(this.certificateAuthorities || []), ...ca]; + } + }; + + if (config.keystore?.path) { + const { key, cert, ca } = readPkcs12Keystore(config.keystore.path, config.keystore.password); + if (!key) { + throw new Error(`Did not find private key in keystore at [keystore.path].`); + } else if (!cert) { + throw new Error(`Did not find certificate in keystore at [keystore.path].`); + } + this.key = key; + this.certificate = cert; + addCAs(ca); + } else if (config.key && config.certificate) { + this.key = readFile(config.key); + this.keyPassphrase = config.keyPassphrase; + this.certificate = readFile(config.certificate); + } + + if (config.truststore?.path) { + const ca = readPkcs12Truststore(config.truststore.path, config.truststore.password); + addCAs(ca); + } + + const ca = config.certificateAuthorities; + if (ca) { + const parsed: string[] = []; + const paths = Array.isArray(ca) ? ca : [ca]; + if (paths.length > 0) { + for (const path of paths) { + parsed.push(readFile(path)); + } + addCAs(parsed); + } + } } /** @@ -117,12 +170,8 @@ export class SslConfig { : secureOptions | secureOption; // eslint-disable-line no-bitwise }, 0); } - - private initCertificateAuthorities(certificateAuthorities?: string[] | string) { - if (certificateAuthorities === undefined || Array.isArray(certificateAuthorities)) { - return certificateAuthorities; - } - - return [certificateAuthorities]; - } } + +const readFile = (file: string) => { + return readFileSync(file, 'utf8'); +}; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index e41f045622a42..72934175f96e5 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -888,7 +888,7 @@ export type KibanaResponseFactory = typeof kibanaResponseFactory; // @public export const kibanaResponseFactory: { - custom: | Buffer | Stream | { + custom: | { message: string | Error; attributes?: Record | undefined; } | undefined>(options: CustomHttpResponseOptions) => KibanaResponse; @@ -899,9 +899,9 @@ export const kibanaResponseFactory: { conflict: (options?: ErrorHttpResponseOptions) => KibanaResponse; internalError: (options?: ErrorHttpResponseOptions) => KibanaResponse; customError: (options: CustomHttpResponseOptions) => KibanaResponse; - redirected: (options: RedirectResponseOptions) => KibanaResponse | Buffer | Stream>; - ok: (options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; - accepted: (options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; + redirected: (options: RedirectResponseOptions) => KibanaResponse>; + ok: (options?: HttpResponseOptions) => KibanaResponse>; + accepted: (options?: HttpResponseOptions) => KibanaResponse>; noContent: (options?: HttpResponseOptions) => KibanaResponse; }; diff --git a/src/core/utils/crypto/__fixtures__/README.md b/src/core/utils/crypto/__fixtures__/README.md new file mode 100644 index 0000000000000..2652b16c420f0 --- /dev/null +++ b/src/core/utils/crypto/__fixtures__/README.md @@ -0,0 +1,44 @@ +# PKCS12 Test Fixtures + +These PKCS12 files are used to test different scenarios. Each has an empty password. + +Including `-noiter` uses a single encryption iteration, and `-nomaciter` uses a single MAC verification iteration. +This makes each P12 keystore much quicker to parse. + +Commands to generate files: + +```shell +# Generate a PKCS12 file with an EE cert and CA cert, but no EE key +cat elasticsearch.crt ca.crt | openssl pkcs12 -export -noiter -nomaciter -passout pass: -nokeys -out no_key.p12 + +# Generate a PKCS12 file with an EE key and EE cert, but no CA cert +cat elasticsearch.key elasticsearch.crt | openssl pkcs12 -export -noiter -nomaciter -passout pass: -out no_ca.p12 + +# Generate a PKCS12 file with an EE key, EE cert, and two CA certs +cat elasticsearch.key elasticsearch.crt ca.crt ca.crt | openssl pkcs12 -export -noiter -nomaciter -passout pass: -out two_cas.p12 + +# Generate a PKCS12 file with two EE keys and EE certs +cat elasticsearch.key elasticsearch.crt | openssl pkcs12 -export -noiter -nomaciter -passout pass: -out two_keys.p12 +cat kibana.key kibana.crt | openssl pkcs12 -export -noiter -nomaciter -passout pass: -name 2 -out tmp.p12 +keytool -importkeystore -srckeystore tmp.p12 -srcstorepass '' -destkeystore two_keys.p12 -deststorepass '' -deststoretype PKCS12 +rm tmp.p12 +``` + +No commonly available tools seem to be able to generate a PKCS12 file with a key and no certificate, so we use node-forge to do that: + +```js +const utils = require('@kbn/dev-utils'); +const forge = require('node-forge'); +const fs = require('fs'); + +const pemCA = fs.readFileSync(utils.CA_CERT_PATH, 'utf8'); +const pemKey = fs.readFileSync(utils.ES_KEY_PATH, 'utf8'); +const privateKey = forge.pki.privateKeyFromPem(pemKey); + +const p12Asn = forge.pkcs12.toPkcs12Asn1(privateKey, pemCA, null, { + useMac: false, + generateLocalKeyId: false, +}); +const p12Der = forge.asn1.toDer(p12Asn).getBytes(); +fs.writeFileSync('no_cert.p12', p12Der, { encoding: 'binary' }); +``` diff --git a/src/core/utils/crypto/__fixtures__/index.ts b/src/core/utils/crypto/__fixtures__/index.ts new file mode 100644 index 0000000000000..12481963ff4f6 --- /dev/null +++ b/src/core/utils/crypto/__fixtures__/index.ts @@ -0,0 +1,26 @@ +/* + * 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 { resolve } from 'path'; + +export const NO_CA_PATH = resolve(__dirname, './no_ca.p12'); +export const NO_CERT_PATH = resolve(__dirname, './no_cert.p12'); +export const NO_KEY_PATH = resolve(__dirname, './no_key.p12'); +export const TWO_CAS_PATH = resolve(__dirname, './two_cas.p12'); +export const TWO_KEYS_PATH = resolve(__dirname, './two_keys.p12'); diff --git a/src/core/utils/crypto/__fixtures__/no_ca.p12 b/src/core/utils/crypto/__fixtures__/no_ca.p12 new file mode 100644 index 0000000000000000000000000000000000000000..1e6df9a0f71c54158e7dbf3b6cad2a062ae3a9d0 GIT binary patch literal 2431 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 B>q`It literal 0 HcmV?d00001 diff --git a/src/core/server/elasticsearch/elasticsearch_client_config.test.mocks.ts b/src/core/utils/crypto/index.ts similarity index 88% rename from src/core/server/elasticsearch/elasticsearch_client_config.test.mocks.ts rename to src/core/utils/crypto/index.ts index f6c6079822cb5..9a36682cc4ecb 100644 --- a/src/core/server/elasticsearch/elasticsearch_client_config.test.mocks.ts +++ b/src/core/utils/crypto/index.ts @@ -17,5 +17,4 @@ * under the License. */ -export const mockReadFileSync = jest.fn(); -jest.mock('fs', () => ({ readFileSync: mockReadFileSync })); +export { Pkcs12ReadResult, readPkcs12Keystore, readPkcs12Truststore } from './pkcs12'; diff --git a/src/core/utils/crypto/pkcs12.test.ts b/src/core/utils/crypto/pkcs12.test.ts new file mode 100644 index 0000000000000..c6c28697c4bcc --- /dev/null +++ b/src/core/utils/crypto/pkcs12.test.ts @@ -0,0 +1,205 @@ +/* + * 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 { + CA_CERT_PATH, + ES_KEY_PATH, + ES_CERT_PATH, + ES_P12_PATH, + ES_P12_PASSWORD, + ES_EMPTYPASSWORD_P12_PATH, + ES_NOPASSWORD_P12_PATH, +} from '@kbn/dev-utils'; +import { NO_CA_PATH, NO_CERT_PATH, NO_KEY_PATH, TWO_CAS_PATH, TWO_KEYS_PATH } from './__fixtures__'; +import { readFileSync } from 'fs'; + +import { readPkcs12Keystore, Pkcs12ReadResult, readPkcs12Truststore } from '.'; + +const reformatPem = (pem: string) => { + // ensure consistency in line endings when comparing two PEM files + return pem.replace(/\r\n/g, '\n').trim(); +}; + +const readPem = (file: string) => { + const raw = readFileSync(file, 'utf8'); + // strip bag attributes that are included from a previous PKCS #12 export + const pem = raw.substr(raw.indexOf('-----BEGIN')); + return reformatPem(pem); +}; + +let pemCA: string; +let pemCert: string; +let pemKey: string; + +beforeAll(() => { + pemCA = readPem(CA_CERT_PATH); + pemCert = readPem(ES_CERT_PATH); + pemKey = readPem(ES_KEY_PATH); +}); + +describe('#readPkcs12Keystore', () => { + const expectKey = (pkcs12ReadResult: Pkcs12ReadResult) => { + const result = reformatPem(pkcs12ReadResult.key!); + expect(result).toEqual(pemKey); + }; + + const expectCert = (pkcs12ReadResult: Pkcs12ReadResult) => { + const result = reformatPem(pkcs12ReadResult.cert!); + expect(result).toEqual(pemCert); + }; + + const expectCA = (pkcs12ReadResult: Pkcs12ReadResult, ca = [pemCA]) => { + const result = pkcs12ReadResult.ca?.map(x => reformatPem(x)); + expect(result).toEqual(ca); + }; + + describe('Succeeds when the correct password is used', () => { + let pkcs12ReadResult: Pkcs12ReadResult; + + beforeAll(() => { + // this is expensive, just do it once + pkcs12ReadResult = readPkcs12Keystore(ES_P12_PATH, ES_P12_PASSWORD); + }); + + it('Extracts the PEM key', () => { + expectKey(pkcs12ReadResult); + }); + + it('Extracts the PEM instance certificate', () => { + expectCert(pkcs12ReadResult); + }); + + it('Extracts the PEM CA certificate', () => { + expectCA(pkcs12ReadResult); + }); + }); + + describe('Succeeds on a key store with an empty password', () => { + let pkcs12ReadResult: Pkcs12ReadResult; + + beforeAll(() => { + // this is expensive, just do it once + pkcs12ReadResult = readPkcs12Keystore(ES_EMPTYPASSWORD_P12_PATH, ''); + }); + + it('Extracts the PEM key', () => { + expectKey(pkcs12ReadResult); + }); + + it('Extracts the PEM instance certificate', () => { + expectCert(pkcs12ReadResult); + }); + + it('Extracts the PEM CA certificate', () => { + expectCA(pkcs12ReadResult); + }); + }); + + describe('Succeeds on a key store with no password', () => { + let pkcs12ReadResult: Pkcs12ReadResult; + + beforeAll(() => { + // this is expensive, just do it once + pkcs12ReadResult = readPkcs12Keystore(ES_NOPASSWORD_P12_PATH); + }); + + it('Extracts the PEM key', () => { + expectKey(pkcs12ReadResult); + }); + + it('Extracts the PEM instance certificate', () => { + expectCert(pkcs12ReadResult); + }); + + it('Extracts the PEM CA certificate', () => { + expectCA(pkcs12ReadResult); + }); + }); + + describe('Handles other key store permutations', () => { + it('Succeeds with no key', () => { + const pkcs12ReadResult = readPkcs12Keystore(NO_KEY_PATH, ''); + expectCA(pkcs12ReadResult, [pemCert, pemCA]); + expect(pkcs12ReadResult.cert).toBeUndefined(); + expect(pkcs12ReadResult.key).toBeUndefined(); + }); + + it('Succeeds with no instance certificate', () => { + const pkcs12ReadResult = readPkcs12Keystore(NO_CERT_PATH, ''); + expectCA(pkcs12ReadResult); + expect(pkcs12ReadResult.cert).toBeUndefined(); + expectKey(pkcs12ReadResult); + }); + + it('Succeeds with no CA certificate', () => { + const pkcs12ReadResult = readPkcs12Keystore(NO_CA_PATH, ''); + expect(pkcs12ReadResult.ca).toBeUndefined(); + expectCert(pkcs12ReadResult); + expectKey(pkcs12ReadResult); + }); + + it('Succeeds with two CA certificates', () => { + const pkcs12ReadResult = readPkcs12Keystore(TWO_CAS_PATH, ''); + expectCA(pkcs12ReadResult, [pemCA, pemCA]); + expectCert(pkcs12ReadResult); + expectKey(pkcs12ReadResult); + }); + }); + + describe('Throws errors', () => { + const expectError = (password?: string) => { + expect(() => readPkcs12Keystore(ES_P12_PATH, password)).toThrowError( + 'PKCS#12 MAC could not be verified. Invalid password?' + ); + }; + + it('When an invalid password is used (incorrect)', () => { + expectError('incorrect'); + }); + + it('When an invalid password is used (empty)', () => { + expectError(''); + }); + + it('When an invalid password is used (undefined)', () => { + expectError(); + }); + + it('When an invalid file path is used', () => { + const path = 'invalid-filepath'; + expect(() => readPkcs12Keystore(path)).toThrowError( + `ENOENT: no such file or directory, open '${path}'` + ); + }); + + it('When two keys are present', () => { + expect(() => readPkcs12Keystore(TWO_KEYS_PATH, '')).toThrowError( + 'Keystore contains multiple private keys.' + ); + }); + }); +}); + +describe('#readPkcs12Truststore', () => { + it('reads all certificates into one CA array and discards any certificates that have keys', () => { + const ca = readPkcs12Truststore(ES_P12_PATH, ES_P12_PASSWORD); + const result = ca?.map(x => reformatPem(x)); + expect(result).toEqual([pemCA]); + }); +}); diff --git a/src/core/utils/crypto/pkcs12.ts b/src/core/utils/crypto/pkcs12.ts new file mode 100644 index 0000000000000..74f22bb685c2f --- /dev/null +++ b/src/core/utils/crypto/pkcs12.ts @@ -0,0 +1,179 @@ +/* + * 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 { util, asn1, pkcs12, pki } from 'node-forge'; +import { readFileSync } from 'fs'; + +export interface Pkcs12ReadResult { + ca?: string[]; + cert?: string; + key?: string; +} + +/** + * Reads a private key and certificate chain from a PKCS12 key store. + * + * @remarks + * The PKCS12 key store may contain the following: + * - 0 or more certificates contained in a `certBag` (OID + * 1.2.840.113549.1.12.10.1.3); if a certificate has an associated + * private key it is treated as an instance certificate, otherwise it is + * treated as a CA certificate + * - 0 or 1 private keys contained in a `keyBag` (OID + * 1.2.840.113549.1.12.10.1.1) or a `pkcs8ShroudedKeyBag` (OID + * 1.2.840.113549.1.12.10.1.2) + * + * Any other PKCS12 bags are ignored. + * + * @privateRemarks + * This intentionally does not allow for a separate key store password and + * private key password. In conventional implementations, these two values + * are expected to be identical, so we do not support other configurations. + * + * @param path The file path of the PKCS12 key store + * @param password The optional password of the key store and private key; + * if there is no password, this may be an empty string or `undefined`, + * depending on how the key store was generated. + * @returns the parsed private key and certificate(s) in PEM format + */ +export const readPkcs12Keystore = (path: string, password?: string): Pkcs12ReadResult => { + const p12base64 = readFileSync(path, 'base64'); + const p12Der = util.decode64(p12base64); + const p12Asn1 = asn1.fromDer(p12Der); + const p12 = pkcs12.pkcs12FromAsn1(p12Asn1, password); + const keyObj = getKey(p12); + const { ca, cert } = getCerts(p12, keyObj?.publicKeyData); + return { ca, cert, key: keyObj?.key }; +}; + +/** + * Reads a certificate chain from a PKCS12 trust store. + * + * @remarks + * The PKCS12 trust store may contain the following: + * - 0 or more certificates contained in a `certBag` (OID + * 1.2.840.113549.1.12.10.1.3); all are treated as CA certificates + * + * Any other PKCS12 bags are ignored. + * + * @param path The file path of the PKCS12 trust store + * @param password The optional password of the trust store; if there is + * no password, this may be an empty string or `undefined`, depending on + * how the trust store was generated. + * @returns the parsed certificate(s) in PEM format + */ +export const readPkcs12Truststore = (path: string, password?: string): string[] | undefined => { + const p12base64 = readFileSync(path, 'base64'); + const p12Der = util.decode64(p12base64); + const p12Asn1 = asn1.fromDer(p12Der); + const p12 = pkcs12.pkcs12FromAsn1(p12Asn1, password); + const keyObj = getKey(p12); + const { ca } = getCerts(p12, keyObj?.publicKeyData); + return ca; +}; + +// jsbn.BigInteger as described in type definition is wrong, it doesn't include `compareTo` +interface BigInteger { + data: number[]; + t: number; + s: number; + toString(): string; + compareTo(bn: BigInteger): number; +} + +interface PublicKeyData { + n: BigInteger; // modulus + e: BigInteger; // public exponent +} + +const doesPubKeyMatch = (a?: PublicKeyData, b?: PublicKeyData) => { + if (a && b) { + return a.n.compareTo(b.n) === 0 && a.e.compareTo(b.e) === 0; + } + return false; +}; + +const getCerts = (p12: pkcs12.Pkcs12Pfx, pubKey?: PublicKeyData) => { + // OID 1.2.840.113549.1.12.10.1.3 (certBag) + const bags = getBags(p12, pki.oids.certBag); + let ca; + let cert; + if (bags && bags.length) { + const certs = bags.map(convertCert).filter(x => x !== undefined); + cert = certs.find(x => doesPubKeyMatch(x!.publicKeyData, pubKey))?.cert; + ca = certs.filter(x => !doesPubKeyMatch(x!.publicKeyData, pubKey)).map(x => x!.cert); + if (ca.length === 0) { + ca = undefined; + } + } + + return { ca, cert }; +}; + +export const convertCert = (bag: pkcs12.Bag) => { + const cert = bag.cert; + if (cert) { + const pem = pki.certificateToPem(cert); + const key = cert.publicKey as pki.rsa.PublicKey; + const publicKeyData: PublicKeyData = { + n: key.n as BigInteger, + e: key.e as BigInteger, + }; + return { + cert: pem, + publicKeyData, + }; + } + return undefined; +}; + +const getKey = (p12: pkcs12.Pkcs12Pfx) => { + // OID 1.2.840.113549.1.12.10.1.1 (keyBag) || OID 1.2.840.113549.1.12.10.1.2 (pkcs8ShroudedKeyBag) + const bags = [ + ...(getBags(p12, pki.oids.keyBag) || []), + ...(getBags(p12, pki.oids.pkcs8ShroudedKeyBag) || []), + ]; + if (bags && bags.length) { + if (bags.length > 1) { + throw new Error(`Keystore contains multiple private keys.`); + } + const key = bags[0].key as pki.rsa.PrivateKey; + if (key) { + const pem = pki.privateKeyToPem(key); + const publicKeyData: PublicKeyData = { + n: key.n as BigInteger, + e: key.e as BigInteger, + }; + return { + key: pem, + publicKeyData, + }; + } + } + return undefined; +}; + +const getBags = (p12: pkcs12.Pkcs12Pfx, bagType: string) => { + const bagObj = p12.getBags({ bagType }); + const bags = bagObj[bagType]; + if (bags && bags.length) { + return bags; + } + return undefined; +}; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 7f8645e2e2e40..7c8ed481c0a7d 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -19,6 +19,7 @@ export * from './assert_never'; export * from './context'; +export * from './crypto'; export * from './deep_freeze'; export * from './get'; export * from './map_to_object'; 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 d1734e836d983..31a5a4c13a4be 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 @@ -34,6 +34,10 @@ kibana_vars=( elasticsearch.ssl.certificateAuthorities elasticsearch.ssl.key elasticsearch.ssl.keyPassphrase + elasticsearch.ssl.keystore.path + elasticsearch.ssl.keystore.password + elasticsearch.ssl.truststore.path + elasticsearch.ssl.truststore.password elasticsearch.ssl.verificationMode elasticsearch.startupTimeout elasticsearch.username @@ -73,6 +77,10 @@ kibana_vars=( server.ssl.enabled server.ssl.key server.ssl.keyPassphrase + server.ssl.keystore.path + server.ssl.keystore.password + server.ssl.truststore.path + server.ssl.truststore.password server.ssl.redirectHttpFromPort server.ssl.supportedProtocols server.xsrf.whitelist diff --git a/src/legacy/core_plugins/console/server/__tests__/elasticsearch_proxy_config.js b/src/legacy/core_plugins/console/server/__tests__/elasticsearch_proxy_config.js index 2942e76f27628..ec7d256975b27 100644 --- a/src/legacy/core_plugins/console/server/__tests__/elasticsearch_proxy_config.js +++ b/src/legacy/core_plugins/console/server/__tests__/elasticsearch_proxy_config.js @@ -19,14 +19,10 @@ import expect from '@kbn/expect'; import moment from 'moment'; -import fs from 'fs'; -import { promisify } from 'bluebird'; import { getElasticsearchProxyConfig } from '../elasticsearch_proxy_config'; import https from 'https'; import http from 'http'; -const readFileAsync = promisify(fs.readFile, fs); - const getDefaultElasticsearchConfig = () => { return { hosts: ['http://localhost:9200', 'http://192.168.1.1:1234'], @@ -124,10 +120,10 @@ describe('plugins/console', function() { it(`sets ca when certificateAuthorities are specified`, function() { const { agent } = getElasticsearchProxyConfig({ ...config, - ssl: { ...config.ssl, certificateAuthorities: [__dirname + '/fixtures/ca.crt'] }, + ssl: { ...config.ssl, certificateAuthorities: ['content-of-some-path'] }, }); - expect(agent.options.ca).to.contain('test ca certificate\n'); + expect(agent.options.ca).to.contain('content-of-some-path'); }); describe('when alwaysPresentCertificate is false', () => { @@ -137,8 +133,8 @@ describe('plugins/console', function() { ssl: { ...config.ssl, alwaysPresentCertificate: false, - certificate: __dirname + '/fixtures/cert.crt', - key: __dirname + '/fixtures/cert.key', + certificate: 'content-of-some-path', + key: 'content-of-another-path', }, }); @@ -152,8 +148,8 @@ describe('plugins/console', function() { ssl: { ...config.ssl, alwaysPresentCertificate: false, - certificate: __dirname + '/fixtures/cert.crt', - key: __dirname + '/fixtures/cert.key', + certificate: 'content-of-some-path', + key: 'content-of-another-path', keyPassphrase: 'secret', }, }); @@ -163,22 +159,19 @@ describe('plugins/console', function() { }); describe('when alwaysPresentCertificate is true', () => { - it(`sets cert and key when certificate and key paths are specified`, async function() { - const certificatePath = __dirname + '/fixtures/cert.crt'; - const keyPath = __dirname + '/fixtures/cert.key'; - + it(`sets cert and key when certificate and key are specified`, async function() { const { agent } = getElasticsearchProxyConfig({ ...config, ssl: { ...config.ssl, alwaysPresentCertificate: true, - certificate: certificatePath, - key: keyPath, + certificate: 'content-of-some-path', + key: 'content-of-another-path', }, }); - expect(agent.options.cert).to.be(await readFileAsync(certificatePath, 'utf8')); - expect(agent.options.key).to.be(await readFileAsync(keyPath, 'utf8')); + expect(agent.options.cert).to.be('content-of-some-path'); + expect(agent.options.key).to.be('content-of-another-path'); }); it(`sets passphrase when certificate, key and keyPassphrase are specified`, function() { @@ -187,8 +180,8 @@ describe('plugins/console', function() { ssl: { ...config.ssl, alwaysPresentCertificate: true, - certificate: __dirname + '/fixtures/cert.crt', - key: __dirname + '/fixtures/cert.key', + certificate: 'content-of-some-path', + key: 'content-of-another-path', keyPassphrase: 'secret', }, }); @@ -197,13 +190,12 @@ describe('plugins/console', function() { }); it(`doesn't set cert when only certificate path is specified`, async function() { - const certificatePath = __dirname + '/fixtures/cert.crt'; const { agent } = getElasticsearchProxyConfig({ ...config, ssl: { ...config.ssl, alwaysPresentCertificate: true, - certificate: certificatePath, + certificate: 'content-of-some-path', key: undefined, }, }); @@ -213,14 +205,13 @@ describe('plugins/console', function() { }); it(`doesn't set key when only key path is specified`, async function() { - const keyPath = __dirname + '/fixtures/cert.key'; const { agent } = getElasticsearchProxyConfig({ ...config, ssl: { ...config.ssl, alwaysPresentCertificate: true, certificate: undefined, - key: keyPath, + key: 'content-of-some-path', }, }); diff --git a/src/legacy/core_plugins/console/server/__tests__/fixtures/ca.crt b/src/legacy/core_plugins/console/server/__tests__/fixtures/ca.crt deleted file mode 100644 index 075fdd038daff..0000000000000 --- a/src/legacy/core_plugins/console/server/__tests__/fixtures/ca.crt +++ /dev/null @@ -1 +0,0 @@ -test ca certificate diff --git a/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.crt b/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.crt deleted file mode 100644 index 360cdfaaaa5a9..0000000000000 --- a/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.crt +++ /dev/null @@ -1 +0,0 @@ -test certificate diff --git a/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.key b/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.key deleted file mode 100644 index 04d3bfef24188..0000000000000 --- a/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.key +++ /dev/null @@ -1 +0,0 @@ -test key diff --git a/src/legacy/core_plugins/console/server/__tests__/proxy_config.js b/src/legacy/core_plugins/console/server/__tests__/proxy_config.js index 821fb8bef64d5..2a221aa261167 100644 --- a/src/legacy/core_plugins/console/server/__tests__/proxy_config.js +++ b/src/legacy/core_plugins/console/server/__tests__/proxy_config.js @@ -21,7 +21,6 @@ import expect from '@kbn/expect'; import sinon from 'sinon'; -import fs from 'fs'; import https, { Agent as HttpsAgent } from 'https'; import { parse as parseUrl } from 'url'; @@ -36,14 +35,6 @@ const parsedGoogle = parseUrl('https://google.com/search'); const parsedLocalEs = parseUrl('https://localhost:5601/search'); describe('ProxyConfig', function() { - beforeEach(function() { - sinon.stub(fs, 'readFileSync').callsFake(path => ({ path })); - }); - - afterEach(function() { - fs.readFileSync.restore(); - }); - describe('constructor', function() { beforeEach(function() { sinon.stub(https, 'Agent'); @@ -56,7 +47,7 @@ describe('ProxyConfig', function() { it('uses ca to create sslAgent', function() { const config = new ProxyConfig({ ssl: { - ca: ['path/to/ca'], + ca: ['content-of-some-path'], }, }); @@ -64,7 +55,7 @@ describe('ProxyConfig', function() { sinon.assert.calledOnce(https.Agent); const sslAgentOpts = https.Agent.firstCall.args[0]; expect(sslAgentOpts).to.eql({ - ca: [{ path: 'path/to/ca' }], + ca: ['content-of-some-path'], cert: undefined, key: undefined, rejectUnauthorized: true, @@ -74,8 +65,8 @@ describe('ProxyConfig', function() { it('uses cert, and key to create sslAgent', function() { const config = new ProxyConfig({ ssl: { - cert: 'path/to/cert', - key: 'path/to/key', + cert: 'content-of-some-path', + key: 'content-of-another-path', }, }); @@ -84,8 +75,8 @@ describe('ProxyConfig', function() { const sslAgentOpts = https.Agent.firstCall.args[0]; expect(sslAgentOpts).to.eql({ ca: undefined, - cert: { path: 'path/to/cert' }, - key: { path: 'path/to/key' }, + cert: 'content-of-some-path', + key: 'content-of-another-path', rejectUnauthorized: true, }); }); @@ -93,9 +84,9 @@ describe('ProxyConfig', function() { it('uses ca, cert, and key to create sslAgent', function() { const config = new ProxyConfig({ ssl: { - ca: ['path/to/ca'], - cert: 'path/to/cert', - key: 'path/to/key', + ca: ['content-of-some-path'], + cert: 'content-of-another-path', + key: 'content-of-yet-another-path', rejectUnauthorized: true, }, }); @@ -104,9 +95,9 @@ describe('ProxyConfig', function() { sinon.assert.calledOnce(https.Agent); const sslAgentOpts = https.Agent.firstCall.args[0]; expect(sslAgentOpts).to.eql({ - ca: [{ path: 'path/to/ca' }], - cert: { path: 'path/to/cert' }, - key: { path: 'path/to/key' }, + ca: ['content-of-some-path'], + cert: 'content-of-another-path', + key: 'content-of-yet-another-path', rejectUnauthorized: true, }); }); diff --git a/src/legacy/core_plugins/console/server/elasticsearch_proxy_config.ts b/src/legacy/core_plugins/console/server/elasticsearch_proxy_config.ts index 9e7c814aa57a4..5f44a524e9cc8 100644 --- a/src/legacy/core_plugins/console/server/elasticsearch_proxy_config.ts +++ b/src/legacy/core_plugins/console/server/elasticsearch_proxy_config.ts @@ -18,13 +18,10 @@ */ import _ from 'lodash'; -import { readFileSync } from 'fs'; import http from 'http'; import https from 'https'; import url from 'url'; -const readFile = (file: string) => readFileSync(file, 'utf8'); - const createAgent = (legacyConfig: any) => { const target = url.parse(_.head(legacyConfig.hosts)); if (!/^https/.test(target.protocol || '')) return new http.Agent(); @@ -49,22 +46,12 @@ const createAgent = (legacyConfig: any) => { throw new Error(`Unknown ssl verificationMode: ${verificationMode}`); } - if ( - legacyConfig.ssl && - Array.isArray(legacyConfig.ssl.certificateAuthorities) && - legacyConfig.ssl.certificateAuthorities.length > 0 - ) { - agentOptions.ca = legacyConfig.ssl.certificateAuthorities.map(readFile); - } + agentOptions.ca = legacyConfig.ssl?.certificateAuthorities; - if ( - legacyConfig.ssl && - legacyConfig.ssl.alwaysPresentCertificate && - legacyConfig.ssl.certificate && - legacyConfig.ssl.key - ) { - agentOptions.cert = readFile(legacyConfig.ssl.certificate); - agentOptions.key = readFile(legacyConfig.ssl.key); + const ignoreCertAndKey = !legacyConfig.ssl?.alwaysPresentCertificate; + if (!ignoreCertAndKey && legacyConfig.ssl?.certificate && legacyConfig.ssl?.key) { + agentOptions.cert = legacyConfig.ssl.certificate; + agentOptions.key = legacyConfig.ssl.key; agentOptions.passphrase = legacyConfig.ssl.keyPassphrase; } diff --git a/src/legacy/core_plugins/console/server/proxy_config.js b/src/legacy/core_plugins/console/server/proxy_config.js index 44a2769a46eb3..b8c1b11f9a0d3 100644 --- a/src/legacy/core_plugins/console/server/proxy_config.js +++ b/src/legacy/core_plugins/console/server/proxy_config.js @@ -20,7 +20,6 @@ import { values } from 'lodash'; import { format as formatUrl } from 'url'; import { Agent as HttpsAgent } from 'https'; -import { readFileSync } from 'fs'; import { WildcardMatcher } from './wildcard_matcher'; @@ -63,9 +62,9 @@ export class ProxyConfig { this.verifySsl = ssl.verify; const sslAgentOpts = { - ca: ssl.ca && ssl.ca.map(ca => readFileSync(ca)), - cert: ssl.cert && readFileSync(ssl.cert), - key: ssl.key && readFileSync(ssl.key), + ca: ssl.ca, + cert: ssl.cert, + key: ssl.key, }; if (values(sslAgentOpts).filter(Boolean).length) { diff --git a/test/server_integration/__fixtures__/README.md b/test/server_integration/__fixtures__/README.md new file mode 100644 index 0000000000000..faf881202e55e --- /dev/null +++ b/test/server_integration/__fixtures__/README.md @@ -0,0 +1,79 @@ +# HTTP SSL Test Fixtures + +These PKCS12 files are used to test SSL with a root CA and an intermediate CA. + +The files that are provided by `@kbn/dev-utils` only use a root CA, so we need additional test files for this. + +To generate these additional test files, see the steps below. + +## Step 1. Set environment variables + +```sh +CA1='test_root_ca' +CA2='test_intermediate_ca' +EE='localhost' +``` + +## Step 2. Generate PKCS12 key stores + +Using [elasticsearch-certutil](https://www.elastic.co/guide/en/elasticsearch/reference/current/certutil.html): + +```sh +bin/elasticsearch-certutil ca --ca-dn "CN=Test Root CA" -days 18250 --out $CA1.p12 --pass castorepass +bin/elasticsearch-certutil ca --ca-dn "CN=Test Intermediate CA" -days 18250 --out $CA2.p12 --pass castorepass +bin/elasticsearch-certutil cert --ca $CA2.p12 --ca-pass castorepass --name $EE --dns $EE --out $EE.p12 --pass storepass +``` + +## Step 3. Convert PKCS12 key stores + +Using OpenSSL on macOS: + +```sh +### CONVERT P12 KEYSTORES TO PEM FILES +openssl pkcs12 -in $CA1.p12 -out $CA1.crt -nokeys -passin pass:"castorepass" -passout pass: +openssl pkcs12 -in $CA1.p12 -nocerts -passin pass:"castorepass" -passout pass:"keypass" | openssl rsa -passin pass:"keypass" -out $CA1.key + +openssl pkcs12 -in $CA2.p12 -out $CA2.crt -nokeys -passin pass:"castorepass" -passout pass: +openssl pkcs12 -in $CA2.p12 -nocerts -passin pass:"castorepass" -passout pass:"keypass" | openssl rsa -passin pass:"keypass" -out $CA2.key + +openssl pkcs12 -in $EE.p12 -out $EE.crt -clcerts -passin pass:"storepass" -passout pass: +openssl pkcs12 -in $EE.p12 -nocerts -passin pass:"storepass" -passout pass:"keypass" | openssl rsa -passin pass:"keypass" -out $EE.key + +### RE-SIGN INTERMEDIATE CA CERT +mkdir -p ./tmp +openssl x509 -x509toreq -in $CA2.crt -signkey $CA2.key -out ./tmp/$CA2.csr +dd if=/dev/urandom of=./tmp/rand bs=256 count=1 +touch ./tmp/index.txt +echo "01" > ./tmp/serial +cp /System/Library/OpenSSL/openssl.cnf ./tmp/ +echo " +[ tmpcnf ] +dir = ./ +certs = ./ +new_certs_dir = ./tmp +crl_dir = ./tmp/crl +database = ./tmp/index.txt +unique_subject = no +certificate = ./$CA1.crt +serial = ./tmp/serial +crlnumber = ./tmp/crlnumber +crl = ./tmp/crl.pem +private_key = ./$CA1.key +RANDFILE = ./tmp/rand +x509_extensions = v3_ca +name_opt = ca_default +cert_opt = ca_default +default_days = 18250 +default_crl_days= 30 +default_md = sha256 +preserve = no +policy = policy_anything +" >> ./tmp/openssl.cnf + +# The next command requires user input +openssl ca -config ./tmp/openssl.cnf -name tmpcnf -in ./tmp/$CA2.csr -out $CA2.crt -verbose + +### CONVERT PEM FILES BACK TO P12 KEYSTORES +cat $CA2.key $CA2.crt $CA1.crt | openssl pkcs12 -export -name $CA2 -passout pass:"castorepass" -out $CA2.p12 +cat $EE.key $EE.crt $CA1.crt $CA2.crt | openssl pkcs12 -export -name $EE -passout pass:"storepass" -out $EE.p12 +``` diff --git a/test/server_integration/__fixtures__/index.ts b/test/server_integration/__fixtures__/index.ts new file mode 100644 index 0000000000000..40f1ddb7fa0ba --- /dev/null +++ b/test/server_integration/__fixtures__/index.ts @@ -0,0 +1,25 @@ +/* + * 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 { resolve } from 'path'; + +export const CA1_CERT_PATH = resolve(__dirname, './test_root_ca.crt'); +export const CA2_CERT_PATH = resolve(__dirname, './test_intermediate_ca.crt'); +export const EE_P12_PATH = resolve(__dirname, './localhost.p12'); +export const EE_P12_PASSWORD = 'storepass'; diff --git a/test/server_integration/__fixtures__/localhost.p12 b/test/server_integration/__fixtures__/localhost.p12 new file mode 100644 index 0000000000000000000000000000000000000000..1b0d11fb880980d2f8ce3c919afe3319cab74360 GIT binary patch literal 4184 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 KibanaSupertestProvider(arg, supertestOptions), + supertest: createKibanaSupertestProvider({ + certificateAuthorities: [readFileSync(CA_CERT_PATH)], + options: supertestOptions, + }), }, servers: { ...httpConfig.get('servers'), @@ -54,8 +59,8 @@ export default async function({ readConfigFile }) { serverArgs: [ ...httpConfig.get('kbnTestServer.serverArgs'), '--server.ssl.enabled=true', - `--server.ssl.key=${require.resolve('../../../dev_certs/server.key')}`, - `--server.ssl.certificate=${require.resolve('../../../dev_certs/server.crt')}`, + `--server.ssl.key=${KBN_KEY_PATH}`, + `--server.ssl.certificate=${KBN_CERT_PATH}`, `--server.ssl.redirectHttpFromPort=${redirectPort}`, ], }, diff --git a/test/server_integration/http/ssl_with_p12/config.js b/test/server_integration/http/ssl_with_p12/config.js new file mode 100644 index 0000000000000..e220914af54f4 --- /dev/null +++ b/test/server_integration/http/ssl_with_p12/config.js @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFileSync } from 'fs'; +import { CA_CERT_PATH, KBN_P12_PATH, KBN_P12_PASSWORD } from '@kbn/dev-utils'; +import { createKibanaSupertestProvider } from '../../services'; + +export default async function({ readConfigFile }) { + const httpConfig = await readConfigFile(require.resolve('../../config')); + + return { + testFiles: [require.resolve('./')], + services: { + ...httpConfig.get('services'), + supertest: createKibanaSupertestProvider({ + certificateAuthorities: [readFileSync(CA_CERT_PATH)], + }), + }, + servers: { + ...httpConfig.get('servers'), + kibana: { + ...httpConfig.get('servers.kibana'), + protocol: 'https', + }, + }, + junit: { + reportName: 'Http SSL Integration Tests', + }, + esTestCluster: httpConfig.get('esTestCluster'), + kbnTestServer: { + ...httpConfig.get('kbnTestServer'), + serverArgs: [ + ...httpConfig.get('kbnTestServer.serverArgs'), + '--server.ssl.enabled=true', + `--server.ssl.keystore.path=${KBN_P12_PATH}`, + `--server.ssl.keystore.password=${KBN_P12_PASSWORD}`, + ], + }, + }; +} diff --git a/test/server_integration/http/ssl_with_p12/index.js b/test/server_integration/http/ssl_with_p12/index.js new file mode 100644 index 0000000000000..700f30ddc21a9 --- /dev/null +++ b/test/server_integration/http/ssl_with_p12/index.js @@ -0,0 +1,28 @@ +/* + * 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 default function({ getService }) { + const supertest = getService('supertest'); + + describe('kibana server with ssl', () => { + it('handles requests using ssl with a P12 keystore', async () => { + await supertest.get('/').expect(302); + }); + }); +} diff --git a/test/server_integration/http/ssl_with_p12_intermediate/config.js b/test/server_integration/http/ssl_with_p12_intermediate/config.js new file mode 100644 index 0000000000000..73a77425ec774 --- /dev/null +++ b/test/server_integration/http/ssl_with_p12_intermediate/config.js @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFileSync } from 'fs'; +import { CA1_CERT_PATH, CA2_CERT_PATH, EE_P12_PATH, EE_P12_PASSWORD } from '../../__fixtures__'; +import { createKibanaSupertestProvider } from '../../services'; + +export default async function({ readConfigFile }) { + const httpConfig = await readConfigFile(require.resolve('../../config')); + + return { + testFiles: [require.resolve('./')], + services: { + ...httpConfig.get('services'), + supertest: createKibanaSupertestProvider({ + certificateAuthorities: [readFileSync(CA1_CERT_PATH), readFileSync(CA2_CERT_PATH)], + }), + }, + servers: { + ...httpConfig.get('servers'), + kibana: { + ...httpConfig.get('servers.kibana'), + protocol: 'https', + }, + }, + junit: { + reportName: 'Http SSL Integration Tests', + }, + esTestCluster: httpConfig.get('esTestCluster'), + kbnTestServer: { + ...httpConfig.get('kbnTestServer'), + serverArgs: [ + ...httpConfig.get('kbnTestServer.serverArgs'), + '--server.ssl.enabled=true', + `--server.ssl.keystore.path=${EE_P12_PATH}`, + `--server.ssl.keystore.password=${EE_P12_PASSWORD}`, + ], + }, + }; +} diff --git a/test/server_integration/http/ssl_with_p12_intermediate/index.js b/test/server_integration/http/ssl_with_p12_intermediate/index.js new file mode 100644 index 0000000000000..fb079a4e091c3 --- /dev/null +++ b/test/server_integration/http/ssl_with_p12_intermediate/index.js @@ -0,0 +1,28 @@ +/* + * 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 default function({ getService }) { + const supertest = getService('supertest'); + + describe('kibana server with ssl', () => { + it('handles requests using ssl with a P12 keystore that uses an intermediate CA', async () => { + await supertest.get('/').expect(302); + }); + }); +} diff --git a/test/server_integration/services/index.js b/test/server_integration/services/index.js index 32a11f30b8683..4904bfc9eeef6 100644 --- a/test/server_integration/services/index.js +++ b/test/server_integration/services/index.js @@ -18,7 +18,7 @@ */ export { - KibanaSupertestProvider, + createKibanaSupertestProvider, KibanaSupertestWithoutAuthProvider, ElasticsearchSupertestProvider, } from './supertest'; diff --git a/test/server_integration/services/supertest.js b/test/server_integration/services/supertest.js index c09932b0207ac..74bb5400bc299 100644 --- a/test/server_integration/services/supertest.js +++ b/test/server_integration/services/supertest.js @@ -17,25 +17,19 @@ * under the License. */ -import { readFileSync } from 'fs'; import { format as formatUrl } from 'url'; import supertestAsPromised from 'supertest-as-promised'; -export function KibanaSupertestProvider({ getService }, options) { - const config = getService('config'); - const kibanaServerUrl = options ? formatUrl(options) : formatUrl(config.get('servers.kibana')); - - const kibanaServerCert = config - .get('kbnTestServer.serverArgs') - .filter(arg => arg.startsWith('--server.ssl.certificate')) - .map(arg => arg.split('=').pop()) - .map(path => readFileSync(path)) - .shift(); +export function createKibanaSupertestProvider({ certificateAuthorities, options } = {}) { + return function({ getService }) { + const config = getService('config'); + const kibanaServerUrl = options ? formatUrl(options) : formatUrl(config.get('servers.kibana')); - return kibanaServerCert - ? supertestAsPromised.agent(kibanaServerUrl, { ca: kibanaServerCert }) - : supertestAsPromised(kibanaServerUrl); + return certificateAuthorities + ? supertestAsPromised.agent(kibanaServerUrl, { ca: certificateAuthorities }) + : supertestAsPromised(kibanaServerUrl); + }; } export function KibanaSupertestWithoutAuthProvider({ getService }) { diff --git a/x-pack/legacy/plugins/security/README.md b/x-pack/legacy/plugins/security/README.md index b4786a2df6c52..068f19ba9482b 100644 --- a/x-pack/legacy/plugins/security/README.md +++ b/x-pack/legacy/plugins/security/README.md @@ -1,7 +1,3 @@ # Kibana Security Plugin -1. Install the Security plugin on Kibana `bin/kibana plugin --install kibana/security/latest` -1. Modify [kibana.yml](https://github.com/elastic/kibana/blob/master/config/kibana.yml) and add `xpack.security.encryptionKey: "something_at_least_32_characters"` -1. Make sure that the following config options are also set: `elasticsearch.username`, `elasticsearch.password`, `server.ssl.certificate`, and `server.ssl.key` (see [Configuring Kibana to Work with Shield](https://www.elastic.co/guide/en/kibana/current/production.html#configuring-kibana-shield)) - -Once done, open up the following url (assuming standard kibana config): [https://localhost:5601](https://localhost:5601). +See [Configuring security in Kibana](https://www.elastic.co/guide/en/kibana/current/using-kibana-with-security.html). diff --git a/x-pack/test/pki_api_integration/config.ts b/x-pack/test/pki_api_integration/config.ts index 50b41ad251827..a445b3d4943b0 100644 --- a/x-pack/test/pki_api_integration/config.ts +++ b/x-pack/test/pki_api_integration/config.ts @@ -7,7 +7,7 @@ import { resolve } from 'path'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; // @ts-ignore -import { CA_CERT_PATH, ES_KEY_PATH, ES_CERT_PATH } from '@kbn/dev-utils'; +import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import { services } from './services'; export default async function({ readConfigFile }: FtrConfigProviderContext) { @@ -54,8 +54,8 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), '--server.ssl.enabled=true', - `--server.ssl.key=${ES_KEY_PATH}`, - `--server.ssl.certificate=${ES_CERT_PATH}`, + `--server.ssl.key=${KBN_KEY_PATH}`, + `--server.ssl.certificate=${KBN_CERT_PATH}`, `--server.ssl.certificateAuthorities=${JSON.stringify([ CA_CERT_PATH, resolve(__dirname, './fixtures/kibana_ca.crt'), diff --git a/x-pack/test/pki_api_integration/fixtures/README.md b/x-pack/test/pki_api_integration/fixtures/README.md index 0fcbc76183b48..ac2be482c6e33 100644 --- a/x-pack/test/pki_api_integration/fixtures/README.md +++ b/x-pack/test/pki_api_integration/fixtures/README.md @@ -1,7 +1,16 @@ # PKI Fixtures -* `es_ca.key` - the CA key used to sign certificates from @kbn/dev-utils that are used and trusted by test Elasticsearch server. -* `first_client.p12` and `second_client.p12` - the client certificate bundles signed by `es_ca.key` and hence trusted by -both test Kibana and Elasticsearch servers. +* `first_client.p12` and `second_client.p12` - the client certificate bundles signed by the Elastic Stack CA (in `kbn-dev-utils`) +and hence trusted by both test Kibana and Elasticsearch servers. * `untrusted_client.p12` - the client certificate bundle trusted by test Kibana server, but not test Elasticsearch test server. -* `kibana_ca.crt` and `kibana_ca.key` - the CA certificate and key trusted by test Kibana server only. \ No newline at end of file +* `kibana_ca.crt` and `kibana_ca.key` - the CA certificate and key trusted by test Kibana server only. + +The `first_client.p12` and `second_client.p12` files were generated the same time as the other certificates in `kbn-dev-utils`, using the +following commands: + +``` +bin/elasticsearch-certutil cert -days 18250 --ca elastic-stack-ca.p12 --ca-pass castorepass --name first_client --pass "" +bin/elasticsearch-certutil cert -days 18250 --ca elastic-stack-ca.p12 --ca-pass castorepass --name second_client --pass "" +``` + +If that CA is ever changed, these two files must be regenerated. diff --git a/x-pack/test/pki_api_integration/fixtures/es_ca.key b/x-pack/test/pki_api_integration/fixtures/es_ca.key deleted file mode 100644 index 5428f86851e5a..0000000000000 --- a/x-pack/test/pki_api_integration/fixtures/es_ca.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEAjSJiqfwPZfvgHO1OZbxzgPn2EW/KewIHXygTAdL926Pm6R45 -G5H972B46NcSUoOZbOhDyvg6OKMJAICiXa85yOf3nyTo4APspR+K4AH60SEJohRF -mZwL/OryfiKvN5n5DxC2+Hb1wouwBUJM6DP62C24ve8YWuWwNkhJqWKe1YQUzPc1 -svqvU5uaHTzvLtp++RqSDNkcIqWl5S9Ip5PtOv6MHkCaIr2g4KQzplFwhT5qVd1Q -nYVBsQ0D8htLqUJBfjW0KHouEZpbjxJlc+EuyExS1o1+y3mVT+t2yZHAoIquh5ve -5A7a/RGJTyoR5u1DFs4Tcx2378kjA86gCQtClwIDAQABAoIBAFTOGKMzxrztQJmh -Lr6LIoyZpnaLygtoCK3xEprCAbB9KD9j3cTnUMMKIR0oPuY+FW8Pkczgo3ts2/fl -U6sfo4VJfc2vDA+vy/7cmUJJbkFDrNorfDb1QW7UbqnEhazPZIzc6lUahkpETZyb -XkMZGN3Ve3EFvojAA8ZaYYjarb52HRddLPZJ7c8ZiHfJ1jHNIvx6dIQ6CJVuovBJ -OGbbSAK8MjUtOI2XzWNHgUqGHcjVDFysuAac3ckK14TaN4KVNRl+usAMkZwqSM5u -j/ATFL9hx7nkzh3KWPsuOLMoLX7JN81z0YtT52wTxJoSiZKk/u91JHZ3NcrsOSPS -oLvVkyECgYEA16qtXvtmboAbqeuXf0nF+7QD0b+MdaRFIacqTG0LpEgY9Tjgs9Pn -6z44tHABWPVkRLNQZiky99MAq4Ci354Bk9dmylCw9ADH78VGmKWklbQEr1rw4dqm -DHTj9NQ79SyTdiasQjnnxCilWkrO6ZUqD8og4DT5MhzfxO/ZND8arGsCgYEAp4df -oI5lwlc1n9X/G9RQAKwNM5un8RmReleUVemjkcvWwvZVEjV0Gcc1WtjB+77Y5B9B -CM3laURDGrAgX5VS/I2jb0xqBNUr8XccSkDQAP9UuVPZgxpS+8d0R3fxVzniHWwR -WC2dW/Is40i/6+7AkFXhkiFiqxkvSg4pWHPazYUCgYB/gP7C+urSRZcVXJ3SuXD9 -oK3pYc/O9XGRtd0CFi4d0CpBQIFIj+27XKv1sYp6Z4oCO+k6nPzvG6Z3vrOMdUQF -fgHddttHRvbtwLo+ISAvCaEDc0aaoMQu9SSYaKmSB+qenbqV5NorVMR9n2C5JGEb -uKq7I1Z41C1Pp2XIx84jRQKBgQCjKvfZsjesZDJnfg9dtJlDPlARXt7gte16gkiI -sOnOfAGtnCzZclSlMuBlnk65enVXIpW+FIQH1iOhn7+4OQE92FpBceSk1ldZdJCK -RbwR7J5Bb0igJ4iBkA9R+KGIOmlgDLyL7MmiHyrXKCk9iynkqrDsGjY2vW3QrCBa -9WQ73QKBgQDAYZzplO4TPoPK9AnxoW/HpSwGEO7Fb8fLyPg94CvHn4QBCFJUKuTn -hBp/TJgF6CjQWQMr2FKVFF33Ow7+Qa96YGvmYlEjR/71D4Rlprj5JJpuO154DI3I -YIMNTjvwEQEI+YamMarKsz0Kq+I1EYSAf6bQ4H2PgxDxwTXaLkl0RA== ------END RSA PRIVATE KEY----- diff --git a/x-pack/test/pki_api_integration/fixtures/first_client.p12 b/x-pack/test/pki_api_integration/fixtures/first_client.p12 index 62da80d9ab80efbedd24edf322acbc1aecc38ef8..9d838199e8392672bfff64155616ac536acb4621 100644 GIT binary patch delta 3295 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 diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml b/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml index 132faffd41667..a890fe812987b 100644 --- a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml +++ b/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml @@ -6,25 +6,25 @@ - - MIIDMDCCAhgCCQCkOD7fnHiQrTANBgkqhkiG9w0BAQUFADBZMQswCQYDVQQGEwJB - VTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0 - cyBQdHkgTHRkMRIwEAYDVQQDEwlsb2NhbGhvc3QwIBcNMTYwMTE5MjExNjQ5WhgP - MjA4NDAyMDYyMTE2NDlaMFkxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0 - YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMT - CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALR63SnR - LW/Dgl0Vuy8gB6KjWwvajRWeXNgvlf6LX56oNsUFREHBLQlC2uT5R26F3tOqCDbs - MFNoyDjMBinXRRFJJ2Sokue7GSsGvBv41LMTHnO/MeCCbEOqghJS/QI89cV+u+Aw - 9U+v426KAlCa1sGuE2+3/JvqdBQyheiukmGLiJ0OofpfgpYuFmKi2uYBKU3qzjUx - D01wQ4rCpq5nEnksGhgBeBDnheYmmDsj/wDvnz1exK/WprvTiHQ5MwuIQ4OybwgV - WDF+zv8PXrObrsZvD/ulrjh1cakvnCe2kDYEKMRiHUDesHS2jNJkBUe+FJo4/E3U - pFoYOtdoBe69BIUCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAQhjF91G0R662XJJ7 - jGMudA9VbRVCow8s68I/GWPZmpKxPAxwz0xiv1eFIoiP416LX9amnx3yAmUoN4Wr - Cq0jsgyT1AOiSCdxkvYsqQG3SFVVt5BDLjThH66Vxi7Bach6SyATa1NG588mg7n9 - pPJ4A1rcj+5kZuwnd52kfVLP+535lylwMyoyJa2AskieRPLNSzus2eUDTR6F+9Mb - eLOwp5rMl2nNYfLXUCSqEeC6uPu0yq6Tu0N0SjThfKndd2NU1fk3zyOjxyCIhGPe - G8VhrPY4lkJ9EE9Tuq095jwd1+q9fYzlKZWhOmg+IcOwUMgbgeWpeZTAhUIZAnia - 4UH6NA== + + MIIDOTCCAiGgAwIBAgIVANNWkg9lzNiLqNkMFhFKHcXyaZmqMA0GCSqGSIb3DQEB +CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu +ZXJhdGVkIENBMCAXDTE5MTIyNzE3MDM0MloYDzIwNjkxMjE0MTcwMzQyWjARMQ8w +DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQ +wYYbQtbRBKJ4uNZc2+IgRU+7NNL21ZebQlEIMgK7jAqOMrsW2b5DATz41Fd+GQFU +FUYYjwo+PQj6sJHshOJo/gNb32HrydvMI7YPvevkszkuEGCfXxQ3Dw2RTACLgD0Q +OCkwHvn3TMf0loloV/ePGWaZDYZaXi3a5DdWi/HFFoJysgF0JV2f6XyKhJkGaEfJ +s9pWX269zH/XQvGNx4BEimJpYB8h4JnDYPFIiQdqj+sl2b+kS1hH9kL5gBAMXjFU +vcNnX+PmyTjyJrGo75k0ku+spBf1bMwuQt3uSmM+TQIXkvFDmS0DOVESrpA5EC1T +BUGRz6o/I88Xx4Mud771AgMBAAGjYzBhMB0GA1UdDgQWBBQLB1Eo23M3Ss8MsFaz +V+Twcb3PmDAfBgNVHSMEGDAWgBQa7SYOe8NGcF00EbwPHA91YCsHSTAUBgNVHREE +DTALgglsb2NhbGhvc3QwCQYDVR0TBAIwADANBgkqhkiG9w0BAQsFAAOCAQEAnEl/ +z5IElIjvkK4AgMPrNcRlvIGDt2orEik7b6Jsq6/RiJQ7cSsYTZf7xbqyxNsUOTxv ++frj47MEN448H2nRvUxH29YR3XygV5aEwADSAhwaQWn0QfWTCZbJTmSoNEDtDOzX +TGDlAoCD9s9Xz9S1JpxY4H+WWRZrBSDM6SC1c6CzuEeZRuScNAjYD5mh2v6fOlSy +b8xJWSg0AFlJPCa3ZsA2SKbNqI0uNfJTnkXRm88Z2NHcgtlADbOLKauWfCrpgsCk +cZgo6yAYkOM148h/8wGla1eX+iE1R72NUABGydu8MSQKvc0emWJkGsC1/KqPlf/O +eOUsdwn1yDKHRxDHyA== diff --git a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts b/x-pack/test/saml_api_integration/fixtures/saml_tools.ts index f8862e6fb209d..b7b94b8eeb17a 100644 --- a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts +++ b/x-pack/test/saml_api_integration/fixtures/saml_tools.ts @@ -12,6 +12,7 @@ import zlib from 'zlib'; import { promisify } from 'util'; import { parseString } from 'xml2js'; import { SignedXml } from 'xml-crypto'; +import { KBN_KEY_PATH } from '@kbn/dev-utils'; /** * @file Defines a set of tools that allow us to parse and generate various SAML XML messages. @@ -24,7 +25,7 @@ const inflateRawAsync = promisify(zlib.inflateRaw); const deflateRawAsync = promisify(zlib.deflateRaw); const parseStringAsync = promisify(parseString); -const signingKey = fs.readFileSync(require.resolve('../../../../test/dev_certs/server.key')); +const signingKey = fs.readFileSync(KBN_KEY_PATH); const signatureAlgorithm = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'; export async function getSAMLRequestId(urlWithSAMLRequestId: string) { diff --git a/yarn.lock b/yarn.lock index 983ad570e0f68..b6e1ca8b7d27f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3882,6 +3882,13 @@ dependencies: "@types/node" "*" +"@types/node-forge@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-0.9.0.tgz#e9f678ec09283f9f35cb8de6c01f86be9278ac08" + integrity sha512-J00+BIHJOfagO1Qs67Jp5CZO3VkFxY8YKMt44oBhXr+3ZYNnl8wv/vtcJyPjuH0QZ+q7+5nnc6o/YH91ZJy2pQ== + dependencies: + "@types/node" "*" + "@types/node-jose@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@types/node-jose/-/node-jose-1.1.0.tgz#26e1d234b41a39035482443ef35414bf34ba5d8b" @@ -20224,6 +20231,11 @@ node-forge@^0.7.6: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw== +node-forge@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.1.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5" + integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ== + node-gyp@^3.8.0: version "3.8.0" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c" From 982df2e5a05e5cdfa2f6aae6fbae760c1c0ad3a4 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Thu, 9 Jan 2020 19:11:15 +0100 Subject: [PATCH 49/63] =?UTF-8?q?fix:=20Table=20visualization=20shows=20to?= =?UTF-8?q?tal=20for=20duration,=20percentage=E2=80=A6=20(#54240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vis_type_table/public/agg_table/agg_table.js | 10 +++------- .../data/common/field_formats/converters/bytes.ts | 1 + .../data/common/field_formats/converters/duration.ts | 1 + .../data/common/field_formats/converters/number.ts | 1 + .../data/common/field_formats/converters/percent.ts | 1 + 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js b/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js index 07870379265c5..3844f809a5257 100644 --- a/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js +++ b/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js @@ -135,18 +135,15 @@ export function KbnAggTable(config, RecursionHelper) { } const isDate = - _.get(dimension, 'format.id') === 'date' || - _.get(dimension, 'format.params.id') === 'date'; - const isNumeric = - _.get(dimension, 'format.id') === 'number' || - _.get(dimension, 'format.params.id') === 'number'; + dimension.format?.id === 'date' || dimension.format?.params?.id === 'date'; + const allowsNumericalAggregations = formatter?.allowsNumericalAggregations; let { totalFunc } = $scope; if (typeof totalFunc === 'undefined' && showPercentage) { totalFunc = 'sum'; } - if (isNumeric || isDate || totalFunc === 'count') { + if (allowsNumericalAggregations || isDate || totalFunc === 'count') { const sum = tableRows => { return _.reduce( tableRows, @@ -161,7 +158,6 @@ export function KbnAggTable(config, RecursionHelper) { }; formattedColumn.sumTotal = sum(table.rows); - switch (totalFunc) { case 'sum': { if (!isDate) { diff --git a/src/plugins/data/common/field_formats/converters/bytes.ts b/src/plugins/data/common/field_formats/converters/bytes.ts index 6c6df5eb7367d..f1110add3e7de 100644 --- a/src/plugins/data/common/field_formats/converters/bytes.ts +++ b/src/plugins/data/common/field_formats/converters/bytes.ts @@ -26,4 +26,5 @@ export class BytesFormat extends NumeralFormat { id = BytesFormat.id; title = BytesFormat.title; + allowsNumericalAggregations = true; } diff --git a/src/plugins/data/common/field_formats/converters/duration.ts b/src/plugins/data/common/field_formats/converters/duration.ts index d02de1a2fd889..8caa11be5ca79 100644 --- a/src/plugins/data/common/field_formats/converters/duration.ts +++ b/src/plugins/data/common/field_formats/converters/duration.ts @@ -171,6 +171,7 @@ export class DurationFormat extends FieldFormat { static fieldType = KBN_FIELD_TYPES.NUMBER; static inputFormats = inputFormats; static outputFormats = outputFormats; + allowsNumericalAggregations = true; isHuman() { return this.param('outputFormat') === HUMAN_FRIENDLY; diff --git a/src/plugins/data/common/field_formats/converters/number.ts b/src/plugins/data/common/field_formats/converters/number.ts index 6969c1551e1cc..686329e887682 100644 --- a/src/plugins/data/common/field_formats/converters/number.ts +++ b/src/plugins/data/common/field_formats/converters/number.ts @@ -26,4 +26,5 @@ export class NumberFormat extends NumeralFormat { id = NumberFormat.id; title = NumberFormat.title; + allowsNumericalAggregations = true; } diff --git a/src/plugins/data/common/field_formats/converters/percent.ts b/src/plugins/data/common/field_formats/converters/percent.ts index 2ae32c7c77f07..d839a54dd0c2c 100644 --- a/src/plugins/data/common/field_formats/converters/percent.ts +++ b/src/plugins/data/common/field_formats/converters/percent.ts @@ -26,6 +26,7 @@ export class PercentFormat extends NumeralFormat { id = PercentFormat.id; title = PercentFormat.title; + allowsNumericalAggregations = true; getParamDefaults = () => ({ pattern: this.getConfig!('format:percent:defaultPattern'), From dfac5d894ec4a2076ab7e0475a6151a7ca021a42 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Thu, 9 Jan 2020 13:14:05 -0500 Subject: [PATCH 50/63] [Monitoring] Ensure setup mode work in a ccs environment (#54361) * Ensure setup mode work in a ccs environment * Missed this file --- .../server/lib/cluster/get_index_patterns.js | 4 +- .../api/v1/setup/cluster_setup_status.js | 3 +- .../routes/api/v1/setup/node_setup_status.js | 3 +- .../apis/monitoring/setup/collection/ccs.js | 42 +++++++++++++++++++ .../apis/monitoring/setup/collection/index.js | 1 + 5 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 x-pack/test/api_integration/apis/monitoring/setup/collection/ccs.js diff --git a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_index_patterns.js b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_index_patterns.js index 823495298e1e6..6e8eb3e3c71d3 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_index_patterns.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_index_patterns.js @@ -13,9 +13,7 @@ import { INDEX_ALERTS, } from '../../../common/constants'; -export function getIndexPatterns(server, additionalPatterns = {}) { - // wildcard means to search _all_ clusters - const ccs = '*'; +export function getIndexPatterns(server, additionalPatterns = {}, ccs = '*') { const config = server.config(); const esIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_ELASTICSEARCH, ccs); const kbnIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_KIBANA, ccs); diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js index 334fcc4ccc26f..2b6f3b6e71d0f 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js @@ -34,6 +34,7 @@ export function clusterSetupStatusRoute(server) { skipLiveData: Joi.boolean().default(false), }), payload: Joi.object({ + ccs: Joi.string().optional(), timeRange: Joi.object({ min: Joi.date().required(), max: Joi.date().required(), @@ -49,7 +50,7 @@ export function clusterSetupStatusRoute(server) { // the monitoring data. `try/catch` makes it a little more explicit. try { await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server); + const indexPatterns = getIndexPatterns(server, {}, req.payload.ccs); status = await getCollectionStatus( req, indexPatterns, diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js index 01715497cbec4..2a615b887500d 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js @@ -34,6 +34,7 @@ export function nodeSetupStatusRoute(server) { skipLiveData: Joi.boolean().default(false), }), payload: Joi.object({ + ccs: Joi.string().optional(), timeRange: Joi.object({ min: Joi.date().required(), max: Joi.date().required(), @@ -49,7 +50,7 @@ export function nodeSetupStatusRoute(server) { // the monitoring data. `try/catch` makes it a little more explicit. try { await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server); + const indexPatterns = getIndexPatterns(server, {}, req.payload.ccs); status = await getCollectionStatus( req, indexPatterns, diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/ccs.js b/x-pack/test/api_integration/apis/monitoring/setup/collection/ccs.js new file mode 100644 index 0000000000000..4c32e311c6cf3 --- /dev/null +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/ccs.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function({ getService }) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('ccs', () => { + const archive = 'monitoring/setup/collection/detect_apm'; + const timeRange = { + min: '2019-04-16T00:00:00.741Z', + max: '2019-04-16T23:59:59.741Z', + }; + + before('load archive', () => { + return esArchiver.load(archive); + }); + + after('unload archive', () => { + return esArchiver.unload(archive); + }); + + it('should not fail with a ccs parameter for cluster', async () => { + await supertest + .post('/api/monitoring/v1/setup/collection/cluster?skipLiveData=true') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, ccs: '*' }) + .expect(200); + }); + + it('should not fail with a ccs parameter for node', async () => { + await supertest + .post('/api/monitoring/v1/setup/collection/node/123?skipLiveData=true') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, ccs: '*' }) + .expect(200); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/index.js b/x-pack/test/api_integration/apis/monitoring/setup/collection/index.js index 48d8b15ecbcad..01594babbc2f4 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/index.js +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/index.js @@ -16,5 +16,6 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./detect_logstash_management')); loadTestFile(require.resolve('./detect_apm')); loadTestFile(require.resolve('./security')); + loadTestFile(require.resolve('./ccs')); }); } From f3f8f800109bd4ee44fec8ab5bcd6a1b8dca6d4a Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Thu, 9 Jan 2020 14:21:46 -0600 Subject: [PATCH 51/63] Management chrome - fix page boundary widths (#54360) * add classes to management chrome, use EuiPageSideBar --- .../management_chrome/management_chrome.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/plugins/management/public/components/management_chrome/management_chrome.tsx b/src/plugins/management/public/components/management_chrome/management_chrome.tsx index 7e5cabd32e48f..df844e2208936 100644 --- a/src/plugins/management/public/components/management_chrome/management_chrome.tsx +++ b/src/plugins/management/public/components/management_chrome/management_chrome.tsx @@ -18,7 +18,7 @@ */ import * as React from 'react'; -import { EuiPage, EuiPageBody } from '@elastic/eui'; +import { EuiPage, EuiPageBody, EuiPageSideBar } from '@elastic/eui'; import { I18nProvider } from '@kbn/i18n/react'; import { ManagementSidebarNav } from '../management_sidebar_nav'; import { LegacySection } from '../../types'; @@ -42,12 +42,14 @@ export class ManagementChrome extends React.Component { return ( - - + + + +

From 73c3e53183bd7749ac5551c93e8b0f71e28c76f4 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Thu, 9 Jan 2020 14:28:57 -0600 Subject: [PATCH 52/63] [TSVB] Add domain fit option to 0 opacity tsvb line charts (#54314) - Line charts is TSVB will no again fit to the data and provide a padding when away from the zero baseline --- package.json | 2 +- .../public/components/vis_types/timeseries/vis.js | 4 ++++ yarn.lock | 8 ++++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index be7c95c14934b..a91e9b80eb49d 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "@babel/core": "^7.5.5", "@babel/register": "^7.7.0", "@elastic/apm-rum": "^4.6.0", - "@elastic/charts": "^16.0.2", + "@elastic/charts": "^16.1.0", "@elastic/datemath": "5.0.2", "@elastic/ems-client": "1.0.5", "@elastic/eui": "17.3.1", diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js index 982aca8d3b813..d269d7c3546ec 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js @@ -79,10 +79,14 @@ export class TimeseriesVisualization extends Component { static getYAxisDomain = model => { const axisMin = get(model, 'axis_min', '').toString(); const axisMax = get(model, 'axis_max', '').toString(); + const fit = model.series + ? model.series.filter(({ hidden }) => !hidden).every(({ fill }) => fill === '0') + : model.fill === '0'; return { min: axisMin.length ? Number(axisMin) : undefined, max: axisMax.length ? Number(axisMax) : undefined, + fit, }; }; diff --git a/yarn.lock b/yarn.lock index b6e1ca8b7d27f..2959b6b3eaf19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1361,10 +1361,10 @@ dependencies: "@elastic/apm-rum-core" "^4.7.0" -"@elastic/charts@^16.0.2": - version "16.0.2" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-16.0.2.tgz#35068a08a19534da62e9bcad700cc7b2a15bc55a" - integrity sha512-0tVyltAmAPOAfiRU1iKYk3b9++4oTn6IXvyM4SSj7Ukh5Y90XXmOtGEUPnZTiRPmup9MJi4srrm9ra9k/Kq4UQ== +"@elastic/charts@^16.1.0": + version "16.1.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-16.1.0.tgz#67cf11625dcd7e1c2cf16ef53349e6a68a73f5b1" + integrity sha512-0jZ7thhGmYC0ZdEVkxfg6M66epCD7k7BfYIi12FnrmIK+mUD2IPhR8b2TJXvaojPryN4YTNreGRncQ9R58fOoQ== dependencies: "@types/d3-shape" "^1.3.1" classnames "^2.2.6" From 6826368842657a7e25b73387708c9cbca0bd9324 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Thu, 9 Jan 2020 16:26:35 -0500 Subject: [PATCH 53/63] Add separate describe blocks for different functional test data sources. (#54395) --- .../api_integration/apis/uptime/rest/index.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/x-pack/test/api_integration/apis/uptime/rest/index.ts b/x-pack/test/api_integration/apis/uptime/rest/index.ts index ef80c1d8728fe..a86411f7c49ec 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/index.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/index.ts @@ -9,10 +9,16 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function({ getService, loadTestFile }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('uptime REST endpoints', () => { - before('load heartbeat data', () => esArchiver.load('uptime/blank')); - after('unload', () => esArchiver.unload('uptime/blank')); - loadTestFile(require.resolve('./snapshot')); - loadTestFile(require.resolve('./monitor_latest_status')); - loadTestFile(require.resolve('./selected_monitor')); + describe('with generated data', () => { + before('load heartbeat data', () => esArchiver.load('uptime/blank')); + after('unload', () => esArchiver.unload('uptime/blank')); + loadTestFile(require.resolve('./snapshot')); + }); + describe('with real-world data', () => { + before('load heartbeat data', () => esArchiver.load('uptime/full_heartbeat')); + after('unload', () => esArchiver.unload('uptime/full_heartbeat')); + loadTestFile(require.resolve('./monitor_latest_status')); + loadTestFile(require.resolve('./selected_monitor')); + }); }); } From 32e61592ecc75c3f5470b6c1f953e62236790da0 Mon Sep 17 00:00:00 2001 From: Phillip Burch Date: Thu, 9 Jan 2020 17:09:57 -0600 Subject: [PATCH 54/63] Remove graphql types (#54176) * Fix server types * Remove graphql types from the frontend * More type cleanup * Replace more types. Delete unused files Co-authored-by: Elastic Machine --- .../infra/common/http_api/node_details_api.ts | 2 +- .../infra/common/http_api/snapshot_api.ts | 31 ++++-- .../aws_ec2/toolbar_items.tsx | 14 +-- .../aws_rds/toolbar_items.tsx | 15 +-- .../inventory_models/aws_s3/toolbar_items.tsx | 14 +-- .../aws_sqs/toolbar_items.tsx | 14 +-- .../container/toolbar_items.tsx | 9 +- .../inventory_models/host/toolbar_items.tsx | 11 +-- .../inventory_models/pod/toolbar_items.tsx | 9 +- .../metrics_and_groupby_toolbar_items.tsx | 4 +- .../public/components/inventory/layout.tsx | 13 +-- .../components/inventory/toolbars/toolbar.tsx | 13 +-- .../inventory/toolbars/toolbar_wrapper.tsx | 98 ++++++++++--------- .../chart_context_menu.test.tsx | 8 +- .../metrics_explorer/chart_context_menu.tsx | 15 +-- .../components/nodes_overview/index.tsx | 40 ++++---- .../components/nodes_overview/table.tsx | 9 +- .../components/waffle/group_of_groups.tsx | 4 +- .../components/waffle/group_of_nodes.tsx | 4 +- .../waffle/lib/create_uptime_link.test.ts | 21 ++-- .../waffle/lib/create_uptime_link.ts | 6 +- .../infra/public/components/waffle/map.tsx | 7 +- .../infra/public/components/waffle/node.tsx | 4 +- .../components/waffle/node_context_menu.tsx | 9 +- .../waffle/waffle_group_by_controls.tsx | 9 +- .../waffle/waffle_inventory_switcher.tsx | 33 +++---- .../waffle/waffle_metric_controls.tsx | 12 +-- .../inventory_metadata/use_inventory_meta.ts | 4 +- .../containers/metadata/use_metadata.ts | 5 +- .../node_details/use_node_details.ts | 6 +- .../containers/waffle/nodes_to_wafflemap.ts | 15 ++- .../public/containers/waffle/use_snaphot.ts | 13 +-- .../containers/waffle/with_waffle_options.tsx | 20 ++-- .../infra/public/containers/with_options.tsx | 3 +- x-pack/legacy/plugins/infra/public/lib/lib.ts | 20 ++-- .../pages/link_to/redirect_to_node_detail.tsx | 6 +- .../pages/link_to/redirect_to_node_logs.tsx | 12 ++- .../pages/metrics/components/helpers.ts | 5 +- .../metrics/components/node_details_page.tsx | 6 +- .../pages/metrics/components/page_body.tsx | 5 +- .../pages/metrics/containers/with_metrics.tsx | 91 ----------------- .../infra/public/pages/metrics/index.tsx | 4 +- .../infra/public/pages/metrics/types.ts | 6 +- .../store/local/waffle_options/actions.ts | 13 +-- .../store/local/waffle_options/reducer.ts | 19 ++-- .../lib/adapters/metrics/adapter_types.ts | 19 ++-- .../metrics/kibana_metrics_adapter.ts | 19 ++-- .../server/lib/domains/metrics_domain.ts | 4 +- .../create_timerange_with_interval.ts | 2 +- .../lib/snapshot/response_helpers.test.ts | 5 +- .../server/lib/snapshot/response_helpers.ts | 25 ++--- .../infra/server/lib/snapshot/snapshot.ts | 6 +- .../infra/server/lib/snapshot/types.ts | 18 +--- .../routes/metadata/lib/get_node_info.ts | 5 +- .../lib/create_metrics_model.ts | 3 +- .../infra/server/routes/node_details/index.ts | 6 +- .../infra/server/routes/snapshot/index.ts | 10 +- .../infra/server/usage/usage_collector.ts | 5 +- .../server/utils/calculate_metric_interval.ts | 4 +- 59 files changed, 336 insertions(+), 476 deletions(-) delete mode 100644 x-pack/legacy/plugins/infra/public/pages/metrics/containers/with_metrics.tsx diff --git a/x-pack/legacy/plugins/infra/common/http_api/node_details_api.ts b/x-pack/legacy/plugins/infra/common/http_api/node_details_api.ts index 46aab881bce4c..0ef5ae82baeb9 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/node_details_api.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/node_details_api.ts @@ -50,6 +50,6 @@ export const NodeDetailsRequestRT = rt.intersection([ ]); // export type NodeDetailsRequest = InfraWrappableRequest; - +export type NodeDetailsMetricData = rt.TypeOf; export type NodeDetailsRequest = rt.TypeOf; export type NodeDetailsMetricDataResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/common/http_api/snapshot_api.ts b/x-pack/legacy/plugins/infra/common/http_api/snapshot_api.ts index 4ee0c9e23b68f..c7c15fd8af161 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/snapshot_api.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/snapshot_api.ts @@ -19,7 +19,7 @@ export const SnapshotNodePathRT = rt.intersection([ const SnapshotNodeMetricOptionalRT = rt.partial({ value: rt.union([rt.number, rt.null]), - average: rt.union([rt.number, rt.null]), + avg: rt.union([rt.number, rt.null]), max: rt.union([rt.number, rt.null]), }); @@ -27,8 +27,12 @@ const SnapshotNodeMetricRequiredRT = rt.type({ name: SnapshotMetricTypeRT, }); +export const SnapshotNodeMetricRT = rt.intersection([ + SnapshotNodeMetricRequiredRT, + SnapshotNodeMetricOptionalRT, +]); export const SnapshotNodeRT = rt.type({ - metric: rt.intersection([SnapshotNodeMetricRequiredRT, SnapshotNodeMetricOptionalRT]), + metric: SnapshotNodeMetricRT, path: rt.array(SnapshotNodePathRT), }); @@ -43,18 +47,24 @@ export const InfraTimerangeInputRT = rt.type({ from: rt.number, }); +export const SnapshotGroupByRT = rt.array( + rt.partial({ + label: rt.union([rt.string, rt.null]), + field: rt.union([rt.string, rt.null]), + }) +); + +export const SnapshotMetricInputRT = rt.type({ + type: SnapshotMetricTypeRT, +}); + export const SnapshotRequestRT = rt.intersection([ rt.type({ timerange: InfraTimerangeInputRT, metric: rt.type({ type: SnapshotMetricTypeRT, }), - groupBy: rt.array( - rt.partial({ - label: rt.union([rt.string, rt.null]), - field: rt.union([rt.string, rt.null]), - }) - ), + groupBy: SnapshotGroupByRT, nodeType: ItemTypeRT, sourceId: rt.string, }), @@ -65,6 +75,11 @@ export const SnapshotRequestRT = rt.intersection([ }), ]); +export type SnapshotNodePath = rt.TypeOf; +export type SnapshotMetricInput = rt.TypeOf; +export type InfraTimerangeInput = rt.TypeOf; +export type SnapshotNodeMetric = rt.TypeOf; +export type SnapshotGroupBy = rt.TypeOf; export type SnapshotRequest = rt.TypeOf; export type SnapshotNode = rt.TypeOf; export type SnapshotNodeResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx index 490b5c552dcc3..4b16728a52bec 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx @@ -7,16 +7,16 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; -import { InfraSnapshotMetricType } from '../../graphql/types'; import { CloudToolbarItems } from '../shared/compontents/cloud_toolbar_items'; +import { SnapshotMetricType } from '../types'; export const AwsEC2ToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.cpu, - InfraSnapshotMetricType.rx, - InfraSnapshotMetricType.tx, - InfraSnapshotMetricType.diskIOReadBytes, - InfraSnapshotMetricType.diskIOWriteBytes, + const metricTypes: SnapshotMetricType[] = [ + 'cpu', + 'rx', + 'tx', + 'diskIOReadBytes', + 'diskIOWriteBytes', ]; const groupByFields = [ 'cloud.availability_zone', diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx index 86ed57e8f4c7f..e4654825a8de1 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx @@ -6,17 +6,18 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; -import { InfraSnapshotMetricType } from '../../../public/graphql/types'; + import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; import { CloudToolbarItems } from '../shared/compontents/cloud_toolbar_items'; +import { SnapshotMetricType } from '../types'; export const AwsRDSToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.cpu, - InfraSnapshotMetricType.rdsConnections, - InfraSnapshotMetricType.rdsQueriesExecuted, - InfraSnapshotMetricType.rdsActiveTransactions, - InfraSnapshotMetricType.rdsLatency, + const metricTypes: SnapshotMetricType[] = [ + 'cpu', + 'rdsConnections', + 'rdsQueriesExecuted', + 'rdsActiveTransactions', + 'rdsLatency', ]; const groupByFields = [ 'cloud.availability_zone', diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx index 276b6b83eb43d..d1aa48260db47 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx @@ -6,17 +6,17 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; -import { InfraSnapshotMetricType } from '../../../public/graphql/types'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; import { CloudToolbarItems } from '../shared/compontents/cloud_toolbar_items'; +import { SnapshotMetricType } from '../types'; export const AwsS3ToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.s3BucketSize, - InfraSnapshotMetricType.s3NumberOfObjects, - InfraSnapshotMetricType.s3TotalRequests, - InfraSnapshotMetricType.s3DownloadBytes, - InfraSnapshotMetricType.s3UploadBytes, + const metricTypes: SnapshotMetricType[] = [ + 's3BucketSize', + 's3NumberOfObjects', + 's3TotalRequests', + 's3DownloadBytes', + 's3UploadBytes', ]; const groupByFields = ['cloud.region']; return ( diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx index 67baa22a5e6b0..9c5ff51b2d9a2 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx @@ -7,16 +7,16 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; -import { InfraSnapshotMetricType } from '../../graphql/types'; import { CloudToolbarItems } from '../shared/compontents/cloud_toolbar_items'; +import { SnapshotMetricType } from '../types'; export const AwsSQSToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.sqsMessagesVisible, - InfraSnapshotMetricType.sqsMessagesDelayed, - InfraSnapshotMetricType.sqsMessagesSent, - InfraSnapshotMetricType.sqsMessagesEmpty, - InfraSnapshotMetricType.sqsOldestMessage, + const metricTypes: SnapshotMetricType[] = [ + 'sqsMessagesVisible', + 'sqsMessagesDelayed', + 'sqsMessagesSent', + 'sqsMessagesEmpty', + 'sqsOldestMessage', ]; const groupByFields = ['cloud.region']; return ( diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/container/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/container/toolbar_items.tsx index 9ed2cbe6dea08..f1e7ea721cabd 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/container/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/container/toolbar_items.tsx @@ -7,15 +7,10 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; -import { InfraSnapshotMetricType } from '../../graphql/types'; +import { SnapshotMetricType } from '../types'; export const ContainerToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.cpu, - InfraSnapshotMetricType.memory, - InfraSnapshotMetricType.rx, - InfraSnapshotMetricType.tx, - ]; + const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx']; const groupByFields = [ 'host.name', 'cloud.availability_zone', diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/host/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/host/toolbar_items.tsx index f8df81a33a8ec..6e0563f2f555b 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/host/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/host/toolbar_items.tsx @@ -7,17 +7,10 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; -import { InfraSnapshotMetricType } from '../../graphql/types'; +import { SnapshotMetricType } from '../types'; export const HostToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.cpu, - InfraSnapshotMetricType.memory, - InfraSnapshotMetricType.load, - InfraSnapshotMetricType.rx, - InfraSnapshotMetricType.tx, - InfraSnapshotMetricType.logRate, - ]; + const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'load', 'rx', 'tx', 'logRate']; const groupByFields = [ 'cloud.availability_zone', 'cloud.machine.type', diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/pod/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/pod/toolbar_items.tsx index 9ef4a889dc589..b075f10a15680 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/pod/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/pod/toolbar_items.tsx @@ -7,15 +7,10 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; -import { InfraSnapshotMetricType } from '../../graphql/types'; +import { SnapshotMetricType } from '../types'; export const PodToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.cpu, - InfraSnapshotMetricType.memory, - InfraSnapshotMetricType.rx, - InfraSnapshotMetricType.tx, - ]; + const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx']; const groupByFields = ['kubernetes.namespace', 'kubernetes.node.name', 'service.type']; return ( void; currentTime: number; onViewChange: (view: string) => void; @@ -30,8 +27,8 @@ export interface LayoutProps { autoBounds: boolean; filterQuery: string | null | undefined; - metric: InfraSnapshotMetricInput; - groupBy: InfraSnapshotGroupbyInput[]; + metric: SnapshotMetricInput; + groupBy: SnapshotGroupBy; sourceId: string; accountId: string; region: string; diff --git a/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar.tsx b/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar.tsx index 1188e486385ea..ee0eceee6d157 100644 --- a/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar.tsx +++ b/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar.tsx @@ -7,13 +7,9 @@ import React, { FunctionComponent } from 'react'; import { Action } from 'typescript-fsa'; import { EuiFlexItem } from '@elastic/eui'; +import { SnapshotMetricInput, SnapshotGroupBy } from '../../../../common/http_api/snapshot_api'; import { InventoryCloudAccount } from '../../../../common/http_api/inventory_meta_api'; import { findToolbar } from '../../../../common/inventory_models/toolbars'; -import { - InfraNodeType, - InfraSnapshotMetricInput, - InfraSnapshotGroupbyInput, -} from '../../../graphql/types'; import { ToolbarWrapper } from './toolbar_wrapper'; import { waffleOptionsSelectors } from '../../../store'; @@ -22,11 +18,12 @@ import { WithWaffleViewState } from '../../../containers/waffle/with_waffle_view import { SavedViewsToolbarControls } from '../../saved_views/toolbar_control'; import { inventoryViewSavedObjectType } from '../../../../common/saved_objects/inventory_view'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/public'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; export interface ToolbarProps { createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => IIndexPattern; - changeMetric: (payload: InfraSnapshotMetricInput) => Action; - changeGroupBy: (payload: InfraSnapshotGroupbyInput[]) => Action; + changeMetric: (payload: SnapshotMetricInput) => Action; + changeGroupBy: (payload: SnapshotGroupBy) => Action; changeCustomOptions: (payload: InfraGroupByOptions[]) => Action; changeAccount: (id: string) => Action; changeRegion: (name: string) => Action; @@ -70,7 +67,7 @@ const wrapToolbarItems = ( }; interface Props { - nodeType: InfraNodeType; + nodeType: InventoryItemType; regions: string[]; accounts: InventoryCloudAccount[]; } diff --git a/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx b/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx index 9a31544ce4cce..231030362438f 100644 --- a/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx +++ b/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx @@ -7,12 +7,12 @@ import React from 'react'; import { EuiFlexGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { SnapshotMetricType } from '../../../../common/inventory_models/types'; import { WithSource } from '../../../containers/with_source'; import { WithWaffleOptions } from '../../../containers/waffle/with_waffle_options'; import { Toolbar } from '../../eui/toolbar'; import { ToolbarProps } from './toolbar'; import { fieldToName } from '../../waffle/lib/field_to_display_name'; -import { InfraSnapshotMetricType } from '../../../graphql/types'; interface Props { children: (props: Omit) => React.ReactElement; @@ -144,123 +144,125 @@ export const toGroupByOpt = (field: string) => ({ field, }); -export const toMetricOpt = (metric: InfraSnapshotMetricType) => { +export const toMetricOpt = ( + metric: SnapshotMetricType +): { text: string; value: SnapshotMetricType } => { switch (metric) { - case InfraSnapshotMetricType.cpu: + case 'cpu': return { text: ToolbarTranslations.CPUUsage, - value: InfraSnapshotMetricType.cpu, + value: 'cpu', }; - case InfraSnapshotMetricType.memory: + case 'memory': return { text: ToolbarTranslations.MemoryUsage, - value: InfraSnapshotMetricType.memory, + value: 'memory', }; - case InfraSnapshotMetricType.rx: + case 'rx': return { text: ToolbarTranslations.InboundTraffic, - value: InfraSnapshotMetricType.rx, + value: 'rx', }; - case InfraSnapshotMetricType.tx: + case 'tx': return { text: ToolbarTranslations.OutboundTraffic, - value: InfraSnapshotMetricType.tx, + value: 'tx', }; - case InfraSnapshotMetricType.logRate: + case 'logRate': return { text: ToolbarTranslations.LogRate, - value: InfraSnapshotMetricType.logRate, + value: 'logRate', }; - case InfraSnapshotMetricType.load: + case 'load': return { text: ToolbarTranslations.Load, - value: InfraSnapshotMetricType.load, + value: 'load', }; - case InfraSnapshotMetricType.count: + case 'count': return { text: ToolbarTranslations.Count, - value: InfraSnapshotMetricType.count, + value: 'count', }; - case InfraSnapshotMetricType.diskIOReadBytes: + case 'diskIOReadBytes': return { text: ToolbarTranslations.DiskIOReadBytes, - value: InfraSnapshotMetricType.diskIOReadBytes, + value: 'diskIOReadBytes', }; - case InfraSnapshotMetricType.diskIOWriteBytes: + case 'diskIOWriteBytes': return { text: ToolbarTranslations.DiskIOWriteBytes, - value: InfraSnapshotMetricType.diskIOWriteBytes, + value: 'diskIOWriteBytes', }; - case InfraSnapshotMetricType.s3BucketSize: + case 's3BucketSize': return { text: ToolbarTranslations.s3BucketSize, - value: InfraSnapshotMetricType.s3BucketSize, + value: 's3BucketSize', }; - case InfraSnapshotMetricType.s3TotalRequests: + case 's3TotalRequests': return { text: ToolbarTranslations.s3TotalRequests, - value: InfraSnapshotMetricType.s3TotalRequests, + value: 's3TotalRequests', }; - case InfraSnapshotMetricType.s3NumberOfObjects: + case 's3NumberOfObjects': return { text: ToolbarTranslations.s3NumberOfObjects, - value: InfraSnapshotMetricType.s3NumberOfObjects, + value: 's3NumberOfObjects', }; - case InfraSnapshotMetricType.s3DownloadBytes: + case 's3DownloadBytes': return { text: ToolbarTranslations.s3DownloadBytes, - value: InfraSnapshotMetricType.s3DownloadBytes, + value: 's3DownloadBytes', }; - case InfraSnapshotMetricType.s3UploadBytes: + case 's3UploadBytes': return { text: ToolbarTranslations.s3UploadBytes, - value: InfraSnapshotMetricType.s3UploadBytes, + value: 's3UploadBytes', }; - case InfraSnapshotMetricType.rdsConnections: + case 'rdsConnections': return { text: ToolbarTranslations.rdsConnections, - value: InfraSnapshotMetricType.rdsConnections, + value: 'rdsConnections', }; - case InfraSnapshotMetricType.rdsQueriesExecuted: + case 'rdsQueriesExecuted': return { text: ToolbarTranslations.rdsQueriesExecuted, - value: InfraSnapshotMetricType.rdsQueriesExecuted, + value: 'rdsQueriesExecuted', }; - case InfraSnapshotMetricType.rdsActiveTransactions: + case 'rdsActiveTransactions': return { text: ToolbarTranslations.rdsActiveTransactions, - value: InfraSnapshotMetricType.rdsActiveTransactions, + value: 'rdsActiveTransactions', }; - case InfraSnapshotMetricType.rdsLatency: + case 'rdsLatency': return { text: ToolbarTranslations.rdsLatency, - value: InfraSnapshotMetricType.rdsLatency, + value: 'rdsLatency', }; - case InfraSnapshotMetricType.sqsMessagesVisible: + case 'sqsMessagesVisible': return { text: ToolbarTranslations.sqsMessagesVisible, - value: InfraSnapshotMetricType.sqsMessagesVisible, + value: 'sqsMessagesVisible', }; - case InfraSnapshotMetricType.sqsMessagesDelayed: + case 'sqsMessagesDelayed': return { text: ToolbarTranslations.sqsMessagesDelayed, - value: InfraSnapshotMetricType.sqsMessagesDelayed, + value: 'sqsMessagesDelayed', }; - case InfraSnapshotMetricType.sqsMessagesSent: + case 'sqsMessagesSent': return { text: ToolbarTranslations.sqsMessagesSent, - value: InfraSnapshotMetricType.sqsMessagesSent, + value: 'sqsMessagesSent', }; - case InfraSnapshotMetricType.sqsMessagesEmpty: + case 'sqsMessagesEmpty': return { text: ToolbarTranslations.sqsMessagesEmpty, - value: InfraSnapshotMetricType.sqsMessagesEmpty, + value: 'sqsMessagesEmpty', }; - case InfraSnapshotMetricType.sqsOldestMessage: + case 'sqsOldestMessage': return { text: ToolbarTranslations.sqsOldestMessage, - value: InfraSnapshotMetricType.sqsOldestMessage, + value: 'sqsOldestMessage', }; } }; diff --git a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx index 6c044baa56d7c..4dd46e9ef233a 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { MetricsExplorerChartContextMenu, createNodeDetailLink } from './chart_context_menu'; import { mount } from 'enzyme'; import { options, source, timeRange, chartOptions } from '../../utils/fixtures/metrics_explorer'; -import { InfraNodeType } from '../../graphql/types'; import DateMath from '@elastic/datemath'; import { ReactWrapper } from 'enzyme'; import { Capabilities } from 'src/core/public'; @@ -163,12 +162,7 @@ describe('MetricsExplorerChartContextMenu', () => { const toDateStrig = '2019-01-01T12:00:00Z'; const to = DateMath.parse(toDateStrig, { roundUp: true })!; const from = DateMath.parse(fromDateStrig)!; - const link = createNodeDetailLink( - InfraNodeType.host, - 'example-01', - fromDateStrig, - toDateStrig - ); + const link = createNodeDetailLink('host', 'example-01', fromDateStrig, toDateStrig); expect(link).toBe( `#/link-to/host-detail/example-01?to=${to.valueOf()}&from=${from.valueOf()}` ); diff --git a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx index 597386fc24ee8..298f7dd8f8d17 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx @@ -21,9 +21,9 @@ import { MetricsExplorerChartOptions, } from '../../containers/metrics_explorer/use_metrics_explorer_options'; import { createTSVBLink } from './helpers/create_tsvb_link'; -import { InfraNodeType } from '../../graphql/types'; import { getNodeDetailUrl } from '../../pages/link_to/redirect_to_node_detail'; import { SourceConfiguration } from '../../utils/source_configuration'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { options: MetricsExplorerOptions; @@ -35,15 +35,18 @@ interface Props { chartOptions: MetricsExplorerChartOptions; } -const fieldToNodeType = (source: SourceConfiguration, field: string): InfraNodeType | undefined => { +const fieldToNodeType = ( + source: SourceConfiguration, + field: string +): InventoryItemType | undefined => { if (source.fields.host === field) { - return InfraNodeType.host; + return 'host'; } if (source.fields.pod === field) { - return InfraNodeType.pod; + return 'pod'; } if (source.fields.container === field) { - return InfraNodeType.container; + return 'container'; } }; @@ -54,7 +57,7 @@ const dateMathExpressionToEpoch = (dateMathExpression: string, roundUp = false): }; export const createNodeDetailLink = ( - nodeType: InfraNodeType, + nodeType: InventoryItemType, nodeId: string, from: string, to: string diff --git a/x-pack/legacy/plugins/infra/public/components/nodes_overview/index.tsx b/x-pack/legacy/plugins/infra/public/components/nodes_overview/index.tsx index 21db0c73f612a..8e8015ce6a82e 100644 --- a/x-pack/legacy/plugins/infra/public/components/nodes_overview/index.tsx +++ b/x-pack/legacy/plugins/infra/public/components/nodes_overview/index.tsx @@ -11,7 +11,6 @@ import { get, max, min } from 'lodash'; import React from 'react'; import euiStyled from '../../../../../common/eui_styled_components'; -import { InfraSnapshotMetricType, InfraSnapshotNode, InfraNodeType } from '../../graphql/types'; import { InfraFormatterType, InfraWaffleMapBounds, InfraWaffleMapOptions } from '../../lib/lib'; import { KueryFilterQuery } from '../../store/local/waffle_filter'; import { createFormatter } from '../../utils/formatters'; @@ -22,10 +21,11 @@ import { ViewSwitcher } from '../waffle/view_switcher'; import { TableView } from './table'; import { SnapshotNode } from '../../../common/http_api/snapshot_api'; import { convertIntervalToString } from '../../utils/convert_interval_to_string'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { options: InfraWaffleMapOptions; - nodeType: InfraNodeType; + nodeType: InventoryItemType; nodes: SnapshotNode[]; loading: boolean; reload: () => void; @@ -49,56 +49,56 @@ interface MetricFormatters { } const METRIC_FORMATTERS: MetricFormatters = { - [InfraSnapshotMetricType.count]: { formatter: InfraFormatterType.number, template: '{{value}}' }, - [InfraSnapshotMetricType.cpu]: { + ['count']: { formatter: InfraFormatterType.number, template: '{{value}}' }, + ['cpu']: { formatter: InfraFormatterType.percent, template: '{{value}}', }, - [InfraSnapshotMetricType.memory]: { + ['memory']: { formatter: InfraFormatterType.percent, template: '{{value}}', }, - [InfraSnapshotMetricType.rx]: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, - [InfraSnapshotMetricType.tx]: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, - [InfraSnapshotMetricType.logRate]: { + ['rx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, + ['tx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, + ['logRate']: { formatter: InfraFormatterType.abbreviatedNumber, template: '{{value}}/s', }, - [InfraSnapshotMetricType.diskIOReadBytes]: { + ['diskIOReadBytes']: { formatter: InfraFormatterType.bytes, template: '{{value}}/s', }, - [InfraSnapshotMetricType.diskIOWriteBytes]: { + ['diskIOWriteBytes']: { formatter: InfraFormatterType.bytes, template: '{{value}}/s', }, - [InfraSnapshotMetricType.s3BucketSize]: { + ['s3BucketSize']: { formatter: InfraFormatterType.bytes, template: '{{value}}', }, - [InfraSnapshotMetricType.s3TotalRequests]: { + ['s3TotalRequests']: { formatter: InfraFormatterType.abbreviatedNumber, template: '{{value}}', }, - [InfraSnapshotMetricType.s3NumberOfObjects]: { + ['s3NumberOfObjects']: { formatter: InfraFormatterType.abbreviatedNumber, template: '{{value}}', }, - [InfraSnapshotMetricType.s3UploadBytes]: { + ['s3UploadBytes']: { formatter: InfraFormatterType.bytes, template: '{{value}}', }, - [InfraSnapshotMetricType.s3DownloadBytes]: { + ['s3DownloadBytes']: { formatter: InfraFormatterType.bytes, template: '{{value}}', }, - [InfraSnapshotMetricType.sqsOldestMessage]: { + ['sqsOldestMessage']: { formatter: InfraFormatterType.number, template: '{{value}} seconds', }, }; -const calculateBoundsFromNodes = (nodes: InfraSnapshotNode[]): InfraWaffleMapBounds => { +const calculateBoundsFromNodes = (nodes: SnapshotNode[]): InfraWaffleMapBounds => { const maxValues = nodes.map(node => node.metric.max); const minValues = nodes.map(node => node.metric.value); // if there is only one value then we need to set the bottom range to zero for min @@ -211,11 +211,7 @@ export const NodesOverview = class extends React.Component { // TODO: Change this to a real implimentation using the tickFormatter from the prototype as an example. private formatter = (val: string | number) => { const { metric } = this.props.options; - const metricFormatter = get( - METRIC_FORMATTERS, - metric.type, - METRIC_FORMATTERS[InfraSnapshotMetricType.count] - ); + const metricFormatter = get(METRIC_FORMATTERS, metric.type, METRIC_FORMATTERS.count); if (val == null) { return ''; } diff --git a/x-pack/legacy/plugins/infra/public/components/nodes_overview/table.tsx b/x-pack/legacy/plugins/infra/public/components/nodes_overview/table.tsx index 8996b170f01d2..dc0de6f6e9c69 100644 --- a/x-pack/legacy/plugins/infra/public/components/nodes_overview/table.tsx +++ b/x-pack/legacy/plugins/infra/public/components/nodes_overview/table.tsx @@ -10,14 +10,15 @@ import { i18n } from '@kbn/i18n'; import { last } from 'lodash'; import React from 'react'; import { createWaffleMapNode } from '../../containers/waffle/nodes_to_wafflemap'; -import { InfraSnapshotNode, InfraSnapshotNodePath, InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib'; import { fieldToName } from '../waffle/lib/field_to_display_name'; import { NodeContextMenu } from '../waffle/node_context_menu'; +import { InventoryItemType } from '../../../common/inventory_models/types'; +import { SnapshotNode, SnapshotNodePath } from '../../../common/http_api/snapshot_api'; interface Props { - nodes: InfraSnapshotNode[]; - nodeType: InfraNodeType; + nodes: SnapshotNode[]; + nodeType: InventoryItemType; options: InfraWaffleMapOptions; formatter: (subject: string | number) => string; currentTime: number; @@ -30,7 +31,7 @@ const initialState = { type State = Readonly; -const getGroupPaths = (path: InfraSnapshotNodePath[]) => { +const getGroupPaths = (path: SnapshotNodePath[]) => { switch (path.length) { case 3: return path.slice(0, 2); diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/group_of_groups.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/group_of_groups.tsx index 7a229fbbe02ec..60a117e3ed617 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/group_of_groups.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/group_of_groups.tsx @@ -7,7 +7,6 @@ import React from 'react'; import euiStyled from '../../../../../common/eui_styled_components'; -import { InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapBounds, InfraWaffleMapGroupOfGroups, @@ -15,6 +14,7 @@ import { } from '../../lib/lib'; import { GroupName } from './group_name'; import { GroupOfNodes } from './group_of_nodes'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { onDrilldown: (filter: string) => void; @@ -22,7 +22,7 @@ interface Props { group: InfraWaffleMapGroupOfGroups; formatter: (val: number) => string; bounds: InfraWaffleMapBounds; - nodeType: InfraNodeType; + nodeType: InventoryItemType; currentTime: number; } diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/group_of_nodes.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/group_of_nodes.tsx index c40c68cbdbf28..b47b8f6a1bf39 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/group_of_nodes.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/group_of_nodes.tsx @@ -7,7 +7,6 @@ import React from 'react'; import euiStyled from '../../../../../common/eui_styled_components'; -import { InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapBounds, InfraWaffleMapGroupOfNodes, @@ -15,6 +14,7 @@ import { } from '../../lib/lib'; import { GroupName } from './group_name'; import { Node } from './node'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { onDrilldown: (filter: string) => void; @@ -23,7 +23,7 @@ interface Props { formatter: (val: number) => string; isChild: boolean; bounds: InfraWaffleMapBounds; - nodeType: InfraNodeType; + nodeType: InventoryItemType; currentTime: number; } diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts b/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts index 6fdf8be15bc9e..1a285ceefec0e 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts +++ b/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts @@ -10,8 +10,7 @@ import { InfraWaffleMapLegendMode, InfraFormatterType, } from '../../../lib/lib'; -import { InfraNodeType } from '../../../../common/graphql/types'; -import { InfraSnapshotMetricType } from '../../../graphql/types'; +import { SnapshotMetricType } from '../../../../common/inventory_models/types'; const options: InfraWaffleMapOptions = { fields: { @@ -24,7 +23,7 @@ const options: InfraWaffleMapOptions = { }, formatter: InfraFormatterType.percent, formatTemplate: '{{value}}', - metric: { type: InfraSnapshotMetricType.cpu }, + metric: { type: 'cpu' }, groupBy: [], legend: { type: InfraWaffleMapLegendMode.gradient, @@ -41,13 +40,13 @@ describe('createUptimeLink()', () => { ip: '10.0.1.2', path: [], metric: { - name: InfraSnapshotMetricType.cpu, + name: 'cpu' as SnapshotMetricType, value: 0.5, max: 0.8, avg: 0.6, }, }; - expect(createUptimeLink(options, InfraNodeType.host, node)).toBe( + expect(createUptimeLink(options, 'host', node)).toBe( '../app/uptime#/?search=host.ip:"10.0.1.2"' ); }); @@ -59,13 +58,13 @@ describe('createUptimeLink()', () => { name: 'host-01', path: [], metric: { - name: InfraSnapshotMetricType.cpu, + name: 'cpu' as SnapshotMetricType, value: 0.5, max: 0.8, avg: 0.6, }, }; - expect(createUptimeLink(options, InfraNodeType.host, node)).toBe( + expect(createUptimeLink(options, 'host', node)).toBe( '../app/uptime#/?search=host.name:"host-01"' ); }); @@ -77,13 +76,13 @@ describe('createUptimeLink()', () => { name: 'pod-01', path: [], metric: { - name: InfraSnapshotMetricType.cpu, + name: 'cpu' as SnapshotMetricType, value: 0.5, max: 0.8, avg: 0.6, }, }; - expect(createUptimeLink(options, InfraNodeType.pod, node)).toBe( + expect(createUptimeLink(options, 'pod', node)).toBe( '../app/uptime#/?search=kubernetes.pod.uid:"29193-pod-02939"' ); }); @@ -95,13 +94,13 @@ describe('createUptimeLink()', () => { name: 'docker-01', path: [], metric: { - name: InfraSnapshotMetricType.cpu, + name: 'cpu' as SnapshotMetricType, value: 0.5, max: 0.8, avg: 0.6, }, }; - expect(createUptimeLink(options, InfraNodeType.container, node)).toBe( + expect(createUptimeLink(options, 'container', node)).toBe( '../app/uptime#/?search=container.id:"docker-1234"' ); }); diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.ts b/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.ts index 7fc0d3cfeee41..05b4b6d514cd0 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.ts +++ b/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.ts @@ -5,17 +5,17 @@ */ import { get } from 'lodash'; -import { InfraNodeType } from '../../../graphql/types'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../lib/lib'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; const BASE_URL = '../app/uptime#/?search='; export const createUptimeLink = ( options: InfraWaffleMapOptions, - nodeType: InfraNodeType, + nodeType: InventoryItemType, node: InfraWaffleMapNode ) => { - if (nodeType === InfraNodeType.host && node.ip) { + if (nodeType === 'host' && node.ip) { return `${BASE_URL}host.ip:"${node.ip}"`; } const field = get(options, ['fields', nodeType], ''); diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/map.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/map.tsx index 6c0209a60f1cd..91fb84840f37f 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/map.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/map.tsx @@ -11,17 +11,18 @@ import { isWaffleMapGroupWithGroups, isWaffleMapGroupWithNodes, } from '../../containers/waffle/type_guards'; -import { InfraSnapshotNode, InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapBounds, InfraWaffleMapOptions } from '../../lib/lib'; import { AutoSizer } from '../auto_sizer'; import { GroupOfGroups } from './group_of_groups'; import { GroupOfNodes } from './group_of_nodes'; import { Legend } from './legend'; import { applyWaffleMapLayout } from './lib/apply_wafflemap_layout'; +import { SnapshotNode } from '../../../common/http_api/snapshot_api'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { - nodes: InfraSnapshotNode[]; - nodeType: InfraNodeType; + nodes: SnapshotNode[]; + nodeType: InventoryItemType; options: InfraWaffleMapOptions; formatter: (subject: string | number) => string; currentTime: number; diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx index f0770064c3cf9..72e2a4c8f35d7 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx @@ -11,10 +11,10 @@ import { i18n } from '@kbn/i18n'; import { ConditionalToolTip } from './conditional_tooltip'; import euiStyled from '../../../../../common/eui_styled_components'; -import { InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapBounds, InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib'; import { colorFromValue } from './lib/color_from_value'; import { NodeContextMenu } from './node_context_menu'; +import { InventoryItemType } from '../../../common/inventory_models/types'; const initialState = { isPopoverOpen: false, @@ -28,7 +28,7 @@ interface Props { node: InfraWaffleMapNode; formatter: (val: number) => string; bounds: InfraWaffleMapBounds; - nodeType: InfraNodeType; + nodeType: InventoryItemType; currentTime: number; } diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/node_context_menu.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/node_context_menu.tsx index cb894f37c1fce..5a90efcc51a57 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/node_context_menu.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/node_context_menu.tsx @@ -13,19 +13,19 @@ import { import { i18n } from '@kbn/i18n'; import React from 'react'; -import { InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib'; import { getNodeDetailUrl, getNodeLogsUrl } from '../../pages/link_to'; import { createUptimeLink } from './lib/create_uptime_link'; import { findInventoryModel } from '../../../common/inventory_models'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { options: InfraWaffleMapOptions; currentTime: number; children: any; node: InfraWaffleMapNode; - nodeType: InfraNodeType; + nodeType: InventoryItemType; isPopoverOpen: boolean; closePopover: () => void; popoverPosition: EuiPopoverProps['anchorPosition']; @@ -47,7 +47,7 @@ export const NodeContextMenu = ({ // We need to have some exceptions until 7.0 & ECS is finalized. Reference // #26620 for the details for these fields. // TODO: This is tech debt, remove it after 7.0 & ECS migration. - const apmField = nodeType === InfraNodeType.host ? 'host.hostname' : inventoryModel.fields.id; + const apmField = nodeType === 'host' ? 'host.hostname' : inventoryModel.fields.id; const nodeLogsMenuItem = { name: i18n.translate('xpack.infra.nodeContextMenu.viewLogsName', { @@ -95,8 +95,7 @@ export const NodeContextMenu = ({ const showAPMTraceLink = inventoryModel.crosslinkSupport.apm && uiCapabilities?.apm && uiCapabilities?.apm.show; const showUptimeLink = - inventoryModel.crosslinkSupport.uptime && - ([InfraNodeType.pod, InfraNodeType.container].includes(nodeType) || node.ip); + inventoryModel.crosslinkSupport.uptime && (['pod', 'container'].includes(nodeType) || node.ip); const items = [ ...(showLogsLink ? [nodeLogsMenuItem] : []), diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx index 0a9df1f666f3d..003eeb96cc41c 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx @@ -17,16 +17,17 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { IFieldType } from 'src/plugins/data/public'; -import { InfraNodeType, InfraSnapshotGroupbyInput } from '../../graphql/types'; import { InfraGroupByOptions } from '../../lib/lib'; import { CustomFieldPanel } from './custom_field_panel'; import euiStyled from '../../../../../common/eui_styled_components'; +import { InventoryItemType } from '../../../common/inventory_models/types'; +import { SnapshotGroupBy } from '../../../common/http_api/snapshot_api'; interface Props { options: Array<{ text: string; field: string; toolTipContent?: string }>; - nodeType: InfraNodeType; - groupBy: InfraSnapshotGroupbyInput[]; - onChange: (groupBy: InfraSnapshotGroupbyInput[]) => void; + nodeType: InventoryItemType; + groupBy: SnapshotGroupBy; + onChange: (groupBy: SnapshotGroupBy) => void; onChangeCustomOptions: (options: InfraGroupByOptions[]) => void; fields: IFieldType[]; customOptions: InfraGroupByOptions[]; diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx index 785531db2ff5e..dec988593b0ce 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx @@ -14,20 +14,15 @@ import { import React, { useCallback, useState, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - InfraSnapshotMetricInput, - InfraSnapshotMetricType, - InfraNodeType, - InfraSnapshotGroupbyInput, -} from '../../graphql/types'; import { findInventoryModel } from '../../../common/inventory_models'; import { InventoryItemType } from '../../../common/inventory_models/types'; +import { SnapshotMetricInput, SnapshotGroupBy } from '../../../common/http_api/snapshot_api'; interface WaffleInventorySwitcherProps { - nodeType: InfraNodeType; - changeNodeType: (nodeType: InfraNodeType) => void; - changeGroupBy: (groupBy: InfraSnapshotGroupbyInput[]) => void; - changeMetric: (metric: InfraSnapshotMetricInput) => void; + nodeType: InventoryItemType; + changeNodeType: (nodeType: InventoryItemType) => void; + changeGroupBy: (groupBy: SnapshotGroupBy) => void; + changeMetric: (metric: SnapshotMetricInput) => void; changeAccount: (id: string) => void; changeRegion: (name: string) => void; } @@ -49,7 +44,7 @@ export const WaffleInventorySwitcher: React.FC = ( const closePopover = useCallback(() => setIsOpen(false), []); const openPopover = useCallback(() => setIsOpen(true), []); const goToNodeType = useCallback( - (targetNodeType: InfraNodeType) => { + (targetNodeType: InventoryItemType) => { closePopover(); changeNodeType(targetNodeType); changeGroupBy([]); @@ -57,18 +52,18 @@ export const WaffleInventorySwitcher: React.FC = ( changeRegion(''); const inventoryModel = findInventoryModel(targetNodeType); changeMetric({ - type: inventoryModel.metrics.defaultSnapshot as InfraSnapshotMetricType, + type: inventoryModel.metrics.defaultSnapshot, }); }, [closePopover, changeNodeType, changeGroupBy, changeMetric, changeAccount, changeRegion] ); - const goToHost = useCallback(() => goToNodeType('host' as InfraNodeType), [goToNodeType]); - const goToK8 = useCallback(() => goToNodeType('pod' as InfraNodeType), [goToNodeType]); - const goToDocker = useCallback(() => goToNodeType('container' as InfraNodeType), [goToNodeType]); - const goToAwsEC2 = useCallback(() => goToNodeType('awsEC2' as InfraNodeType), [goToNodeType]); - const goToAwsS3 = useCallback(() => goToNodeType('awsS3' as InfraNodeType), [goToNodeType]); - const goToAwsRDS = useCallback(() => goToNodeType('awsRDS' as InfraNodeType), [goToNodeType]); - const goToAwsSQS = useCallback(() => goToNodeType('awsSQS' as InfraNodeType), [goToNodeType]); + const goToHost = useCallback(() => goToNodeType('host'), [goToNodeType]); + const goToK8 = useCallback(() => goToNodeType('pod'), [goToNodeType]); + const goToDocker = useCallback(() => goToNodeType('container'), [goToNodeType]); + const goToAwsEC2 = useCallback(() => goToNodeType('awsEC2'), [goToNodeType]); + const goToAwsS3 = useCallback(() => goToNodeType('awsS3'), [goToNodeType]); + const goToAwsRDS = useCallback(() => goToNodeType('awsRDS'), [goToNodeType]); + const goToAwsSQS = useCallback(() => goToNodeType('awsSQS'), [goToNodeType]); const panels = useMemo( () => [ diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_metric_controls.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_metric_controls.tsx index d5ae6fcf7f7a2..f9e48730eaaf2 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_metric_controls.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_metric_controls.tsx @@ -14,13 +14,13 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; - -import { InfraSnapshotMetricInput, InfraSnapshotMetricType } from '../../graphql/types'; +import { SnapshotMetricInput } from '../../../common/http_api/snapshot_api'; +import { SnapshotMetricType } from '../../../common/inventory_models/types'; interface Props { - options: Array<{ text: string; value: InfraSnapshotMetricType }>; - metric: InfraSnapshotMetricInput; - onChange: (metric: InfraSnapshotMetricInput) => void; + options: Array<{ text: string; value: SnapshotMetricType }>; + metric: SnapshotMetricInput; + onChange: (metric: SnapshotMetricInput) => void; } const initialState = { @@ -90,7 +90,7 @@ export const WaffleMetricControls = class extends React.PureComponent ({ isPopoverOpen: !state.isPopoverOpen })); }; - private handleClick = (value: InfraSnapshotMetricType) => () => { + private handleClick = (value: SnapshotMetricType) => () => { this.props.onChange({ type: value }); this.handleClose(); }; diff --git a/x-pack/legacy/plugins/infra/public/containers/inventory_metadata/use_inventory_meta.ts b/x-pack/legacy/plugins/infra/public/containers/inventory_metadata/use_inventory_meta.ts index c7a7b4d61bd12..0ed1f3e35449b 100644 --- a/x-pack/legacy/plugins/infra/public/containers/inventory_metadata/use_inventory_meta.ts +++ b/x-pack/legacy/plugins/infra/public/containers/inventory_metadata/use_inventory_meta.ts @@ -13,9 +13,9 @@ import { InventoryMetaResponseRT, InventoryMetaResponse, } from '../../../common/http_api/inventory_meta_api'; -import { InfraNodeType } from '../../graphql/types'; +import { InventoryItemType } from '../../../common/inventory_models/types'; -export function useInventoryMeta(sourceId: string, nodeType: InfraNodeType) { +export function useInventoryMeta(sourceId: string, nodeType: InventoryItemType) { const decodeResponse = (response: any) => { return pipe( InventoryMetaResponseRT.decode(response), diff --git a/x-pack/legacy/plugins/infra/public/containers/metadata/use_metadata.ts b/x-pack/legacy/plugins/infra/public/containers/metadata/use_metadata.ts index 83e176aea1039..52c522ce8efd4 100644 --- a/x-pack/legacy/plugins/infra/public/containers/metadata/use_metadata.ts +++ b/x-pack/legacy/plugins/infra/public/containers/metadata/use_metadata.ts @@ -8,16 +8,15 @@ import { useEffect } from 'react'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; -import { InfraNodeType } from '../../graphql/types'; import { InfraMetadata, InfraMetadataRT } from '../../../common/http_api/metadata_api'; import { useHTTPRequest } from '../../hooks/use_http_request'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; -import { InventoryMetric } from '../../../common/inventory_models/types'; +import { InventoryMetric, InventoryItemType } from '../../../common/inventory_models/types'; import { getFilteredMetrics } from './lib/get_filtered_metrics'; export function useMetadata( nodeId: string, - nodeType: InfraNodeType, + nodeType: InventoryItemType, requiredMetrics: InventoryMetric[], sourceId: string ) { diff --git a/x-pack/legacy/plugins/infra/public/containers/node_details/use_node_details.ts b/x-pack/legacy/plugins/infra/public/containers/node_details/use_node_details.ts index e86e56a591199..189c48ba1a70c 100644 --- a/x-pack/legacy/plugins/infra/public/containers/node_details/use_node_details.ts +++ b/x-pack/legacy/plugins/infra/public/containers/node_details/use_node_details.ts @@ -6,19 +6,19 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; -import { InfraNodeType, InfraTimerangeInput } from '../../graphql/types'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; import { useHTTPRequest } from '../../hooks/use_http_request'; import { NodeDetailsMetricDataResponseRT, NodeDetailsMetricDataResponse, } from '../../../common/http_api/node_details_api'; -import { InventoryMetric } from '../../../common/inventory_models/types'; +import { InventoryMetric, InventoryItemType } from '../../../common/inventory_models/types'; +import { InfraTimerangeInput } from '../../../common/http_api/snapshot_api'; export function useNodeDetails( metrics: InventoryMetric[], nodeId: string, - nodeType: InfraNodeType, + nodeType: InventoryItemType, sourceId: string, timerange: InfraTimerangeInput, cloudId: string diff --git a/x-pack/legacy/plugins/infra/public/containers/waffle/nodes_to_wafflemap.ts b/x-pack/legacy/plugins/infra/public/containers/waffle/nodes_to_wafflemap.ts index 18595d4788630..d2511ba7e669e 100644 --- a/x-pack/legacy/plugins/infra/public/containers/waffle/nodes_to_wafflemap.ts +++ b/x-pack/legacy/plugins/infra/public/containers/waffle/nodes_to_wafflemap.ts @@ -6,8 +6,6 @@ import { i18n } from '@kbn/i18n'; import { first, last } from 'lodash'; - -import { InfraSnapshotNode, InfraSnapshotNodePath } from '../../graphql/types'; import { InfraWaffleMapGroup, InfraWaffleMapGroupOfGroups, @@ -15,14 +13,15 @@ import { InfraWaffleMapNode, } from '../../lib/lib'; import { isWaffleMapGroupWithGroups, isWaffleMapGroupWithNodes } from './type_guards'; +import { SnapshotNodePath, SnapshotNode } from '../../../common/http_api/snapshot_api'; -export function createId(path: InfraSnapshotNodePath[]) { +export function createId(path: SnapshotNodePath[]) { return path.map(p => p.value).join('/'); } function findOrCreateGroupWithNodes( groups: InfraWaffleMapGroup[], - path: InfraSnapshotNodePath[] + path: SnapshotNodePath[] ): InfraWaffleMapGroupOfNodes { const id = path.length === 0 ? '__all__' : createId(path); /** @@ -62,7 +61,7 @@ function findOrCreateGroupWithNodes( function findOrCreateGroupWithGroups( groups: InfraWaffleMapGroup[], - path: InfraSnapshotNodePath[] + path: SnapshotNodePath[] ): InfraWaffleMapGroupOfGroups { const id = path.length === 0 ? '__all__' : createId(path); const lastPath = last(path); @@ -85,7 +84,7 @@ function findOrCreateGroupWithGroups( }; } -export function createWaffleMapNode(node: InfraSnapshotNode): InfraWaffleMapNode { +export function createWaffleMapNode(node: SnapshotNode): InfraWaffleMapNode { const nodePathItem = last(node.path); if (!nodePathItem) { throw new Error('There must be at least one node path item'); @@ -106,8 +105,8 @@ function withoutGroup(group: InfraWaffleMapGroup) { }; } -export function nodesToWaffleMap(nodes: InfraSnapshotNode[]): InfraWaffleMapGroup[] { - return nodes.reduce((groups: InfraWaffleMapGroup[], node: InfraSnapshotNode) => { +export function nodesToWaffleMap(nodes: SnapshotNode[]): InfraWaffleMapGroup[] { + return nodes.reduce((groups: InfraWaffleMapGroup[], node: SnapshotNode) => { const waffleNode = createWaffleMapNode(node); if (node.path.length === 2) { const parentGroup = findOrCreateGroupWithNodes( diff --git a/x-pack/legacy/plugins/infra/public/containers/waffle/use_snaphot.ts b/x-pack/legacy/plugins/infra/public/containers/waffle/use_snaphot.ts index d318d81235309..31f02f46caeda 100644 --- a/x-pack/legacy/plugins/infra/public/containers/waffle/use_snaphot.ts +++ b/x-pack/legacy/plugins/infra/public/containers/waffle/use_snaphot.ts @@ -8,23 +8,20 @@ import { useEffect } from 'react'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; -import { - InfraNodeType, - InfraSnapshotMetricInput, - InfraSnapshotGroupbyInput, -} from '../../graphql/types'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; import { useHTTPRequest } from '../../hooks/use_http_request'; import { SnapshotNodeResponseRT, SnapshotNodeResponse, + SnapshotGroupBy, } from '../../../common/http_api/snapshot_api'; +import { InventoryItemType, SnapshotMetricType } from '../../../common/inventory_models/types'; export function useSnapshot( filterQuery: string | null | undefined, - metric: InfraSnapshotMetricInput, - groupBy: InfraSnapshotGroupbyInput[], - nodeType: InfraNodeType, + metric: { type: SnapshotMetricType }, + groupBy: SnapshotGroupBy, + nodeType: InventoryItemType, sourceId: string, currentTime: number, accountId: string, diff --git a/x-pack/legacy/plugins/infra/public/containers/waffle/with_waffle_options.tsx b/x-pack/legacy/plugins/infra/public/containers/waffle/with_waffle_options.tsx index 6d10a753f9ed0..45222b32aa51f 100644 --- a/x-pack/legacy/plugins/infra/public/containers/waffle/with_waffle_options.tsx +++ b/x-pack/legacy/plugins/infra/public/containers/waffle/with_waffle_options.tsx @@ -9,17 +9,17 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { isBoolean, isNumber } from 'lodash'; -import { - InfraSnapshotMetricInput, - InfraSnapshotMetricType, - InfraNodeType, - InfraSnapshotGroupbyInput, -} from '../../graphql/types'; import { InfraGroupByOptions } from '../../lib/lib'; import { State, waffleOptionsActions, waffleOptionsSelectors } from '../../store'; import { asChildFunctionRenderer } from '../../utils/typed_react'; import { bindPlainActionCreators } from '../../utils/typed_redux'; import { UrlStateContainer } from '../../utils/url_state'; +import { SnapshotMetricInput, SnapshotGroupBy } from '../../../common/http_api/snapshot_api'; +import { + SnapshotMetricTypeRT, + InventoryItemType, + ItemTypeRT, +} from '../../../common/inventory_models/types'; const selectOptionsUrlState = createSelector( waffleOptionsSelectors.selectMetric, @@ -194,13 +194,13 @@ const mapToUrlState = (value: any): WaffleOptionsUrlState | undefined => } : undefined; -const isInfraNodeType = (value: any): value is InfraNodeType => value in InfraNodeType; +const isInfraNodeType = (value: any): value is InventoryItemType => value in ItemTypeRT; -const isInfraSnapshotMetricInput = (subject: any): subject is InfraSnapshotMetricInput => { - return subject != null && subject.type in InfraSnapshotMetricType; +const isInfraSnapshotMetricInput = (subject: any): subject is SnapshotMetricInput => { + return subject != null && subject.type in SnapshotMetricTypeRT; }; -const isInfraSnapshotGroupbyInput = (subject: any): subject is InfraSnapshotGroupbyInput => { +const isInfraSnapshotGroupbyInput = (subject: any): subject is SnapshotGroupBy => { return subject != null && subject.type != null; }; diff --git a/x-pack/legacy/plugins/infra/public/containers/with_options.tsx b/x-pack/legacy/plugins/infra/public/containers/with_options.tsx index 349d7fda74f1f..4294697fd4103 100644 --- a/x-pack/legacy/plugins/infra/public/containers/with_options.tsx +++ b/x-pack/legacy/plugins/infra/public/containers/with_options.tsx @@ -7,7 +7,6 @@ import moment from 'moment'; import React from 'react'; -import { InfraSnapshotMetricType } from '../graphql/types'; import { InfraFormatterType, InfraOptions, InfraWaffleMapLegendMode } from '../lib/lib'; import { RendererFunction } from '../utils/typed_react'; @@ -24,7 +23,7 @@ const initialState = { wafflemap: { formatter: InfraFormatterType.percent, formatTemplate: '{{value}}', - metric: { type: InfraSnapshotMetricType.cpu }, + metric: { type: 'cpu' }, groupBy: [], legend: { type: InfraWaffleMapLegendMode.gradient, diff --git a/x-pack/legacy/plugins/infra/public/lib/lib.ts b/x-pack/legacy/plugins/infra/public/lib/lib.ts index 4b402ce6beafe..9f851e185018b 100644 --- a/x-pack/legacy/plugins/infra/public/lib/lib.ts +++ b/x-pack/legacy/plugins/infra/public/lib/lib.ts @@ -10,14 +10,14 @@ import ApolloClient from 'apollo-client'; import { AxiosRequestConfig } from 'axios'; import React from 'react'; import { Observable } from 'rxjs'; +import { SourceQuery } from '../graphql/types'; import { - InfraSnapshotMetricInput, - InfraSnapshotNodeMetric, - InfraSnapshotNodePath, - InfraSnapshotGroupbyInput, + SnapshotMetricInput, + SnapshotGroupBy, InfraTimerangeInput, - SourceQuery, -} from '../graphql/types'; + SnapshotNodeMetric, + SnapshotNodePath, +} from '../../common/http_api/snapshot_api'; export interface InfraFrontendLibs { apolloClient: InfraApolloClient; @@ -102,8 +102,8 @@ export interface InfraWaffleMapNode { id: string; name: string; ip?: string | null; - path: InfraSnapshotNodePath[]; - metric: InfraSnapshotNodeMetric; + path: SnapshotNodePath[]; + metric: SnapshotNodeMetric; } export type InfraWaffleMapGroup = InfraWaffleMapGroupOfNodes | InfraWaffleMapGroupOfGroups; @@ -165,8 +165,8 @@ export interface InfraWaffleMapOptions { fields?: SourceQuery.Query['source']['configuration']['fields'] | null; formatter: InfraFormatterType; formatTemplate: string; - metric: InfraSnapshotMetricInput; - groupBy: InfraSnapshotGroupbyInput[]; + metric: SnapshotMetricInput; + groupBy: SnapshotGroupBy; legend: InfraWaffleMapLegend; } diff --git a/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx b/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx index a2cebbb96a4f0..ec1cc8ba45332 100644 --- a/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { Redirect, RouteComponentProps } from 'react-router-dom'; import { replaceMetricTimeInQueryString } from '../metrics/containers/with_metrics_time'; -import { InfraNodeType } from '../../graphql/types'; import { getFromFromLocation, getToFromLocation } from './query_params'; +import { InventoryItemType } from '../../../common/inventory_models/types'; type RedirectToNodeDetailProps = RouteComponentProps<{ nodeId: string; - nodeType: InfraNodeType; + nodeType: InventoryItemType; }>; export const RedirectToNodeDetail = ({ @@ -36,7 +36,7 @@ export const getNodeDetailUrl = ({ to, from, }: { - nodeType: InfraNodeType; + nodeType: InventoryItemType; nodeId: string; to?: number; from?: number; diff --git a/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx b/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx index 99b558cf1cafa..6f1fdd606dd73 100644 --- a/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx @@ -14,18 +14,22 @@ import { LoadingPage } from '../../components/loading_page'; import { replaceLogFilterInQueryString } from '../../containers/logs/log_filter'; import { replaceLogPositionInQueryString } from '../../containers/logs/log_position'; import { replaceSourceIdInQueryString } from '../../containers/source_id'; -import { InfraNodeType, SourceConfigurationFields } from '../../graphql/types'; +import { SourceConfigurationFields } from '../../graphql/types'; import { getFilterFromLocation, getTimeFromLocation } from './query_params'; import { useSource } from '../../containers/source/source'; import { findInventoryFields } from '../../../common/inventory_models'; +import { InventoryItemType } from '../../../common/inventory_models/types'; type RedirectToNodeLogsType = RouteComponentProps<{ nodeId: string; - nodeType: InfraNodeType; + nodeType: InventoryItemType; sourceId?: string; }>; -const getFieldByNodeType = (nodeType: InfraNodeType, fields: SourceConfigurationFields.Fields) => { +const getFieldByNodeType = ( + nodeType: InventoryItemType, + fields: SourceConfigurationFields.Fields +) => { const inventoryFields = findInventoryFields(nodeType, fields); return inventoryFields.id; }; @@ -75,6 +79,6 @@ export const getNodeLogsUrl = ({ time, }: { nodeId: string; - nodeType: InfraNodeType; + nodeType: InventoryItemType; time?: number; }) => [`#/link-to/${nodeType}-logs/`, nodeId, ...(time ? [`?time=${time}`] : [])].join(''); diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/components/helpers.ts b/x-pack/legacy/plugins/infra/public/pages/metrics/components/helpers.ts index 68c459983bd72..4449196f2fb53 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/components/helpers.ts +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/components/helpers.ts @@ -8,13 +8,14 @@ import { ReactText } from 'react'; import Color from 'color'; import { get, first, last, min, max } from 'lodash'; import { createFormatter } from '../../../utils/formatters'; -import { InfraDataSeries, InfraMetricData } from '../../../graphql/types'; +import { InfraDataSeries } from '../../../graphql/types'; import { InventoryVisTypeRT, InventoryFormatterType, InventoryVisType, } from '../../../../common/inventory_models/types'; import { SeriesOverrides } from '../types'; +import { NodeDetailsMetricData } from '../../../../common/http_api/node_details_api'; /** * Returns a formatter @@ -34,7 +35,7 @@ export const seriesHasLessThen2DataPoints = (series: InfraDataSeries): boolean = /** * Returns the minimum and maximum timestamp for a metric */ -export const getMaxMinTimestamp = (metric: InfraMetricData): [number, number] => { +export const getMaxMinTimestamp = (metric: NodeDetailsMetricData): [number, number] => { if (metric.series.some(seriesHasLessThen2DataPoints)) { return [0, 0]; } diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/components/node_details_page.tsx b/x-pack/legacy/plugins/infra/public/pages/metrics/components/node_details_page.tsx index 2f4eb57cd5161..cc662f8fd5b57 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/components/node_details_page.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/components/node_details_page.tsx @@ -13,9 +13,9 @@ import { EuiHideFor, EuiTitle, } from '@elastic/eui'; -import { InventoryMetric } from '../../../../common/inventory_models/types'; +import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; +import { InventoryMetric, InventoryItemType } from '../../../../common/inventory_models/types'; import { useNodeDetails } from '../../../containers/node_details/use_node_details'; -import { InfraNodeType, InfraTimerangeInput } from '../../../graphql/types'; import { MetricsSideNav } from './side_nav'; import { AutoSizer } from '../../../components/auto_sizer'; import { MetricsTimeControls } from './time_controls'; @@ -32,7 +32,7 @@ interface Props { requiredMetrics: InventoryMetric[]; nodeId: string; cloudId: string; - nodeType: InfraNodeType; + nodeType: InventoryItemType; sourceId: string; timeRange: MetricsTimeInput; parsedTimeRange: InfraTimerangeInput; diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/components/page_body.tsx b/x-pack/legacy/plugins/infra/public/pages/metrics/components/page_body.tsx index 81c96c0ffe68d..414b9c60adee3 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/components/page_body.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/components/page_body.tsx @@ -8,16 +8,17 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { findLayout } from '../../../../common/inventory_models/layouts'; import { InventoryItemType } from '../../../../common/inventory_models/types'; -import { InfraMetricData } from '../../../graphql/types'; + import { MetricsTimeInput } from '../containers/with_metrics_time'; import { InfraLoadingPanel } from '../../../components/loading'; import { NoData } from '../../../components/empty_states'; +import { NodeDetailsMetricData } from '../../../../common/http_api/node_details_api'; interface Props { loading: boolean; refetch: () => void; type: InventoryItemType; - metrics: InfraMetricData[]; + metrics: NodeDetailsMetricData[]; onChangeRangeTime?: (time: MetricsTimeInput) => void; isLiveStreaming?: boolean; stopLiveStreaming?: () => void; diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/containers/with_metrics.tsx b/x-pack/legacy/plugins/infra/public/pages/metrics/containers/with_metrics.tsx deleted file mode 100644 index 6f7e411628d27..0000000000000 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/containers/with_metrics.tsx +++ /dev/null @@ -1,91 +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 { ApolloError } from 'apollo-client'; -import React from 'react'; -import { Query } from 'react-apollo'; -import { i18n } from '@kbn/i18n'; -import { - InfraMetric, - InfraMetricData, - InfraNodeType, - MetricsQuery, - InfraTimerangeInput, -} from '../../../graphql/types'; -import { metricsQuery } from './metrics.gql_query'; -import { InventoryMetric, InventoryMetricRT } from '../../../../common/inventory_models/types'; - -interface WithMetricsArgs { - metrics: InfraMetricData[]; - error?: ApolloError | undefined; - loading: boolean; - refetch: () => void; -} - -interface WithMetricsProps { - children: (args: WithMetricsArgs) => React.ReactNode; - requiredMetrics: InventoryMetric[]; - nodeType: InfraNodeType; - nodeId: string; - cloudId: string; - sourceId: string; - timerange: InfraTimerangeInput; -} - -const isInfraMetrics = (subject: any[]): subject is InfraMetric[] => { - return subject.every(s => InventoryMetricRT.is(s)); -}; - -export const WithMetrics = ({ - children, - requiredMetrics, - sourceId, - timerange, - nodeType, - nodeId, - cloudId, -}: WithMetricsProps) => { - if (!isInfraMetrics(requiredMetrics)) { - throw new Error( - i18n.translate('xpack.infra.invalidInventoryMetricsError', { - defaultMessage: 'One of the InfraMetric is invalid', - }) - ); - } - - return ( - - query={metricsQuery} - fetchPolicy="no-cache" - notifyOnNetworkStatusChange - variables={{ - sourceId, - metrics: requiredMetrics, - nodeType, - nodeId, - cloudId, - timerange, - }} - > - {({ data, error, loading, refetch }) => { - return children({ - metrics: filterOnlyInfraMetricData(data && data.source && data.source.metrics), - error, - loading, - refetch, - }); - }} - - ); -}; - -const filterOnlyInfraMetricData = ( - metrics: Array | undefined -): InfraMetricData[] => { - if (!metrics) { - return []; - } - return metrics.filter(m => m !== null).map(m => m as InfraMetricData); -}; diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx b/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx index b7e8274802555..4c0d2d7c93d7a 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx @@ -10,7 +10,6 @@ import { DocumentTitle } from '../../components/document_title'; import { Header } from '../../components/header'; import { ColumnarPage, PageContent } from '../../components/page'; import { WithMetricsTime, WithMetricsTimeUrlState } from './containers/with_metrics_time'; -import { InfraNodeType } from '../../graphql/types'; import { withMetricPageProviders } from './page_providers'; import { useMetadata } from '../../containers/metadata/use_metadata'; import { Source } from '../../containers/source'; @@ -19,6 +18,7 @@ import { findInventoryModel } from '../../../common/inventory_models'; import { NavItem } from './lib/side_nav_context'; import { NodeDetailsPage } from './components/node_details_page'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { InventoryItemType } from '../../../common/inventory_models/types'; const DetailPageContent = euiStyled(PageContent)` overflow: auto; @@ -39,7 +39,7 @@ export const MetricDetail = withMetricPageProviders( withTheme(({ match, theme }: Props) => { const uiCapabilities = useKibana().services.application?.capabilities; const nodeId = match.params.node; - const nodeType = match.params.type as InfraNodeType; + const nodeType = match.params.type as InventoryItemType; const inventoryModel = findInventoryModel(nodeType); const { sourceId } = useContext(Source.Context); const { diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/types.ts b/x-pack/legacy/plugins/infra/public/pages/metrics/types.ts index e752164796150..4b9e32fddcc76 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/types.ts +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/types.ts @@ -6,12 +6,12 @@ import rt from 'io-ts'; import { EuiTheme } from '../../../../../common/eui_styled_components'; -import { InfraMetricData } from '../../graphql/types'; import { InventoryFormatterTypeRT } from '../../../common/inventory_models/types'; import { MetricsTimeInput } from './containers/with_metrics_time'; +import { NodeDetailsMetricData } from '../../../common/http_api/node_details_api'; export interface LayoutProps { - metrics?: InfraMetricData[]; + metrics?: NodeDetailsMetricData[]; onChangeRangeTime?: (time: MetricsTimeInput) => void; isLiveStreaming?: boolean; stopLiveStreaming?: () => void; @@ -55,7 +55,7 @@ export const VisSectionPropsRT = rt.partial({ export type VisSectionProps = rt.TypeOf & { id?: string; - metric?: InfraMetricData; + metric?: NodeDetailsMetricData; onChangeRangeTime?: (time: MetricsTimeInput) => void; isLiveStreaming?: boolean; stopLiveStreaming?: () => void; diff --git a/x-pack/legacy/plugins/infra/public/store/local/waffle_options/actions.ts b/x-pack/legacy/plugins/infra/public/store/local/waffle_options/actions.ts index d72e9e05f2aea..4a1b45084b08a 100644 --- a/x-pack/legacy/plugins/infra/public/store/local/waffle_options/actions.ts +++ b/x-pack/legacy/plugins/infra/public/store/local/waffle_options/actions.ts @@ -5,19 +5,16 @@ */ import actionCreatorFactory from 'typescript-fsa'; -import { - InfraSnapshotMetricInput, - InfraNodeType, - InfraSnapshotGroupbyInput, -} from '../../../graphql/types'; +import { SnapshotGroupBy, SnapshotMetricInput } from '../../../../common/http_api/snapshot_api'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; import { InfraGroupByOptions, InfraWaffleMapBounds } from '../../../lib/lib'; const actionCreator = actionCreatorFactory('x-pack/infra/local/waffle_options'); -export const changeMetric = actionCreator('CHANGE_METRIC'); -export const changeGroupBy = actionCreator('CHANGE_GROUP_BY'); +export const changeMetric = actionCreator('CHANGE_METRIC'); +export const changeGroupBy = actionCreator('CHANGE_GROUP_BY'); export const changeCustomOptions = actionCreator('CHANGE_CUSTOM_OPTIONS'); -export const changeNodeType = actionCreator('CHANGE_NODE_TYPE'); +export const changeNodeType = actionCreator('CHANGE_NODE_TYPE'); export const changeView = actionCreator('CHANGE_VIEW'); export const changeBoundsOverride = actionCreator('CHANGE_BOUNDS_OVERRIDE'); export const changeAutoBounds = actionCreator('CHANGE_AUTO_BOUNDS'); diff --git a/x-pack/legacy/plugins/infra/public/store/local/waffle_options/reducer.ts b/x-pack/legacy/plugins/infra/public/store/local/waffle_options/reducer.ts index a8aa91695b3a5..9d86ffe612a28 100644 --- a/x-pack/legacy/plugins/infra/public/store/local/waffle_options/reducer.ts +++ b/x-pack/legacy/plugins/infra/public/store/local/waffle_options/reducer.ts @@ -6,13 +6,7 @@ import { combineReducers } from 'redux'; import { reducerWithInitialState } from 'typescript-fsa-reducers'; - -import { - InfraSnapshotMetricInput, - InfraSnapshotMetricType, - InfraNodeType, - InfraSnapshotGroupbyInput, -} from '../../../graphql/types'; +import { SnapshotMetricInput, SnapshotGroupBy } from '../../../../common/http_api/snapshot_api'; import { InfraGroupByOptions, InfraWaffleMapBounds } from '../../../lib/lib'; import { changeAutoBounds, @@ -25,11 +19,12 @@ import { changeAccount, changeRegion, } from './actions'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; export interface WaffleOptionsState { - metric: InfraSnapshotMetricInput; - groupBy: InfraSnapshotGroupbyInput[]; - nodeType: InfraNodeType; + metric: SnapshotMetricInput; + groupBy: SnapshotGroupBy; + nodeType: InventoryItemType; view: string; customOptions: InfraGroupByOptions[]; boundsOverride: InfraWaffleMapBounds; @@ -39,9 +34,9 @@ export interface WaffleOptionsState { } export const initialWaffleOptionsState: WaffleOptionsState = { - metric: { type: InfraSnapshotMetricType.cpu }, + metric: { type: 'cpu' }, groupBy: [], - nodeType: InfraNodeType.host, + nodeType: 'host', view: 'map', customOptions: [], boundsOverride: { max: 1, min: 0 }, diff --git a/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/adapter_types.ts b/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/adapter_types.ts index 844eaf7604927..6659cb060b1a8 100644 --- a/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/adapter_types.ts +++ b/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/adapter_types.ts @@ -6,22 +6,19 @@ import { RequestHandlerContext, KibanaRequest } from 'src/core/server'; import { - InfraMetric, - InfraMetricData, - InfraNodeType, - InfraTimerangeInput, -} from '../../../graphql/types'; + NodeDetailsRequest, + NodeDetailsMetricData, +} from '../../../../common/http_api/node_details_api'; +import { InventoryMetric } from '../../../../common/inventory_models/types'; import { InfraSourceConfiguration } from '../../sources'; -export interface InfraMetricsRequestOptions { +export interface InfraMetricsRequestOptions + extends Omit { nodeIds: { nodeId: string; cloudId?: string | null; }; - nodeType: InfraNodeType; sourceConfiguration: InfraSourceConfiguration; - timerange: InfraTimerangeInput; - metrics: InfraMetric[]; } export interface InfraMetricsAdapter { @@ -29,7 +26,7 @@ export interface InfraMetricsAdapter { requestContext: RequestHandlerContext, options: InfraMetricsRequestOptions, request: KibanaRequest - ): Promise; + ): Promise; } export enum InfraMetricModelQueryType { @@ -52,7 +49,7 @@ export enum InfraMetricModelMetricType { } export interface InfraMetricModel { - id: InfraMetric; + id: InventoryMetric; requires: string[]; index_pattern: string | string[]; interval: string; diff --git a/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts b/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts index 6acb8afbfb249..eb5ac05644a22 100644 --- a/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts +++ b/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts @@ -7,12 +7,16 @@ import { i18n } from '@kbn/i18n'; import { flatten, get } from 'lodash'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; -import { InfraMetric, InfraMetricData } from '../../../graphql/types'; +import { NodeDetailsMetricData } from '../../../../common/http_api/node_details_api'; import { KibanaFramework } from '../framework/kibana_framework_adapter'; import { InfraMetricsAdapter, InfraMetricsRequestOptions } from './adapter_types'; import { checkValidNode } from './lib/check_valid_node'; import { metrics, findInventoryFields } from '../../../../common/inventory_models'; -import { TSVBMetricModelCreator } from '../../../../common/inventory_models/types'; +import { + TSVBMetricModelCreator, + InventoryMetric, + InventoryMetricRT, +} from '../../../../common/inventory_models/types'; import { calculateMetricInterval } from '../../../utils/calculate_metric_interval'; export class KibanaMetricsAdapter implements InfraMetricsAdapter { @@ -26,7 +30,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { requestContext: RequestHandlerContext, options: InfraMetricsRequestOptions, rawRequest: KibanaRequest - ): Promise { + ): Promise { const indexPattern = `${options.sourceConfiguration.metricAlias},${options.sourceConfiguration.logAlias}`; const fields = findInventoryFields(options.nodeType, options.sourceConfiguration.fields); const nodeField = fields.id; @@ -58,8 +62,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { ); return metricIds.map((id: string) => { - const infraMetricId: InfraMetric = (InfraMetric as any)[id]; - if (!infraMetricId) { + if (!InventoryMetricRT.is(id)) { throw new Error( i18n.translate('xpack.infra.kibanaMetrics.invalidInfraMetricErrorMessage', { defaultMessage: '{id} is not a valid InfraMetric', @@ -69,9 +72,9 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { }) ); } - const panel = result[infraMetricId]; + const panel = result[id]; return { - id: infraMetricId, + id, series: panel.series.map(series => { return { id: series.id, @@ -87,7 +90,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { } async makeTSVBRequest( - metricId: InfraMetric, + metricId: InventoryMetric, options: InfraMetricsRequestOptions, nodeField: string, requestContext: RequestHandlerContext diff --git a/x-pack/legacy/plugins/infra/server/lib/domains/metrics_domain.ts b/x-pack/legacy/plugins/infra/server/lib/domains/metrics_domain.ts index e53e45afae5c4..ac76e264ff0ed 100644 --- a/x-pack/legacy/plugins/infra/server/lib/domains/metrics_domain.ts +++ b/x-pack/legacy/plugins/infra/server/lib/domains/metrics_domain.ts @@ -5,8 +5,8 @@ */ import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; -import { InfraMetricData } from '../../graphql/types'; import { InfraMetricsAdapter, InfraMetricsRequestOptions } from '../adapters/metrics/adapter_types'; +import { NodeDetailsMetricData } from '../../../common/http_api/node_details_api'; export class InfraMetricsDomain { private adapter: InfraMetricsAdapter; @@ -19,7 +19,7 @@ export class InfraMetricsDomain { requestContext: RequestHandlerContext, options: InfraMetricsRequestOptions, rawRequest: KibanaRequest - ): Promise { + ): Promise { return await this.adapter.getMetrics(requestContext, options, rawRequest); } } diff --git a/x-pack/legacy/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts b/x-pack/legacy/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts index 4bb1aefb5b2c7..6f036475a1e1c 100644 --- a/x-pack/legacy/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts +++ b/x-pack/legacy/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts @@ -7,12 +7,12 @@ import { uniq } from 'lodash'; import { RequestHandlerContext } from 'kibana/server'; import { InfraSnapshotRequestOptions } from './types'; -import { InfraTimerangeInput } from '../../../public/graphql/types'; import { getMetricsAggregations } from './query_helpers'; import { calculateMetricInterval } from '../../utils/calculate_metric_interval'; import { SnapshotModel, SnapshotModelMetricAggRT } from '../../../common/inventory_models/types'; import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; import { getDatasetForField } from '../../routes/metrics_explorer/lib/get_dataset_for_field'; +import { InfraTimerangeInput } from '../../../common/http_api/snapshot_api'; export const createTimeRangeWithInterval = async ( framework: KibanaFramework, diff --git a/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.test.ts b/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.test.ts index 28146624a8a89..20220aef1133d 100644 --- a/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.test.ts +++ b/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.test.ts @@ -5,7 +5,6 @@ */ import { isIPv4, getIPFromBucket, InfraSnapshotNodeGroupByBucket } from './response_helpers'; -import { InfraNodeType } from '../../graphql/types'; describe('InfraOps ResponseHelpers', () => { describe('isIPv4', () => { @@ -44,7 +43,7 @@ describe('InfraOps ResponseHelpers', () => { }, }, }; - expect(getIPFromBucket(InfraNodeType.host, bucket)).toBe('192.168.1.4'); + expect(getIPFromBucket('host', bucket)).toBe('192.168.1.4'); }); it('should NOT return ipv6 address', () => { const bucket: InfraSnapshotNodeGroupByBucket = { @@ -72,7 +71,7 @@ describe('InfraOps ResponseHelpers', () => { }, }, }; - expect(getIPFromBucket(InfraNodeType.host, bucket)).toBe(null); + expect(getIPFromBucket('host', bucket)).toBe(null); }); }); }); diff --git a/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.ts b/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.ts index d22f41ff152f7..12f284c363bd5 100644 --- a/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.ts +++ b/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.ts @@ -7,15 +7,11 @@ import { isNumber, last, max, sum, get } from 'lodash'; import moment from 'moment'; -import { - InfraSnapshotMetricType, - InfraSnapshotNodePath, - InfraSnapshotNodeMetric, - InfraNodeType, -} from '../../graphql/types'; import { getIntervalInSeconds } from '../../utils/get_interval_in_seconds'; import { InfraSnapshotRequestOptions } from './types'; import { findInventoryModel } from '../../../common/inventory_models'; +import { InventoryItemType, SnapshotMetricType } from '../../../common/inventory_models/types'; +import { SnapshotNodeMetric, SnapshotNodePath } from '../../../common/http_api/snapshot_api'; export interface InfraSnapshotNodeMetricsBucket { key: { id: string }; @@ -70,7 +66,7 @@ export interface InfraSnapshotNodeGroupByBucket { export const isIPv4 = (subject: string) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(subject); export const getIPFromBucket = ( - nodeType: InfraNodeType, + nodeType: InventoryItemType, bucket: InfraSnapshotNodeGroupByBucket ): string | null => { const inventoryModel = findInventoryModel(nodeType); @@ -92,10 +88,10 @@ export const getIPFromBucket = ( export const getNodePath = ( groupBucket: InfraSnapshotNodeGroupByBucket, options: InfraSnapshotRequestOptions -): InfraSnapshotNodePath[] => { +): SnapshotNodePath[] => { const node = groupBucket.key; const path = options.groupBy.map(gb => { - return { value: node[`${gb.field}`], label: node[`${gb.field}`] } as InfraSnapshotNodePath; + return { value: node[`${gb.field}`], label: node[`${gb.field}`] } as SnapshotNodePath; }); const ip = getIPFromBucket(options.nodeType, groupBucket); path.push({ value: node.id, label: node.name || node.id, ip }); @@ -121,7 +117,7 @@ export const getNodeMetricsForLookup = ( export const getNodeMetrics = ( nodeBuckets: InfraSnapshotMetricsBucket[], options: InfraSnapshotRequestOptions -): InfraSnapshotNodeMetric => { +): SnapshotNodeMetric => { if (!nodeBuckets) { return { name: options.metric.type, @@ -156,18 +152,15 @@ const findLastFullBucket = ( }, last(buckets)); }; -const getMetricValueFromBucket = ( - type: InfraSnapshotMetricType, - bucket: InfraSnapshotMetricsBucket -) => { +const getMetricValueFromBucket = (type: SnapshotMetricType, bucket: InfraSnapshotMetricsBucket) => { const metric = bucket[type]; return (metric && (metric.normalized_value || metric.value)) || 0; }; -function calculateMax(buckets: InfraSnapshotMetricsBucket[], type: InfraSnapshotMetricType) { +function calculateMax(buckets: InfraSnapshotMetricsBucket[], type: SnapshotMetricType) { return max(buckets.map(bucket => getMetricValueFromBucket(type, bucket))) || 0; } -function calculateAvg(buckets: InfraSnapshotMetricsBucket[], type: InfraSnapshotMetricType) { +function calculateAvg(buckets: InfraSnapshotMetricsBucket[], type: SnapshotMetricType) { return sum(buckets.map(bucket => getMetricValueFromBucket(type, bucket))) / buckets.length || 0; } diff --git a/x-pack/legacy/plugins/infra/server/lib/snapshot/snapshot.ts b/x-pack/legacy/plugins/infra/server/lib/snapshot/snapshot.ts index 1a724673608a2..ef453be76d8d7 100644 --- a/x-pack/legacy/plugins/infra/server/lib/snapshot/snapshot.ts +++ b/x-pack/legacy/plugins/infra/server/lib/snapshot/snapshot.ts @@ -5,7 +5,6 @@ */ import { RequestHandlerContext } from 'src/core/server'; -import { InfraSnapshotNode } from '../../graphql/types'; import { InfraDatabaseSearchResponse } from '../adapters/framework'; import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; import { InfraSources } from '../sources'; @@ -30,6 +29,7 @@ import { createAfterKeyHandler } from '../../utils/create_afterkey_handler'; import { findInventoryModel } from '../../../common/inventory_models'; import { InfraSnapshotRequestOptions } from './types'; import { createTimeRangeWithInterval } from './create_timerange_with_interval'; +import { SnapshotNode } from '../../../common/http_api/snapshot_api'; export class InfraSnapshot { constructor(private readonly libs: { sources: InfraSources; framework: KibanaFramework }) {} @@ -37,7 +37,7 @@ export class InfraSnapshot { public async getNodes( requestContext: RequestHandlerContext, options: InfraSnapshotRequestOptions - ): Promise<{ nodes: InfraSnapshotNode[]; interval: string }> { + ): Promise<{ nodes: SnapshotNode[]; interval: string }> { // Both requestGroupedNodes and requestNodeMetrics may send several requests to elasticsearch // in order to page through the results of their respective composite aggregations. // Both chains of requests are supposed to run in parallel, and their results be merged @@ -186,7 +186,7 @@ const mergeNodeBuckets = ( nodeGroupByBuckets: InfraSnapshotNodeGroupByBucket[], nodeMetricsBuckets: InfraSnapshotNodeMetricsBucket[], options: InfraSnapshotRequestOptions -): InfraSnapshotNode[] => { +): SnapshotNode[] => { const nodeMetricsForLookup = getNodeMetricsForLookup(nodeMetricsBuckets); return nodeGroupByBuckets.map(node => { diff --git a/x-pack/legacy/plugins/infra/server/lib/snapshot/types.ts b/x-pack/legacy/plugins/infra/server/lib/snapshot/types.ts index 31823b2811121..3627747e327e0 100644 --- a/x-pack/legacy/plugins/infra/server/lib/snapshot/types.ts +++ b/x-pack/legacy/plugins/infra/server/lib/snapshot/types.ts @@ -5,21 +5,11 @@ */ import { JsonObject } from '../../../common/typed_json'; -import { - InfraNodeType, - InfraSourceConfiguration, - InfraTimerangeInput, - InfraSnapshotGroupbyInput, - InfraSnapshotMetricInput, -} from '../../../public/graphql/types'; +import { InfraSourceConfiguration } from '../../../public/graphql/types'; +import { SnapshotRequest } from '../../../common/http_api/snapshot_api'; -export interface InfraSnapshotRequestOptions { - nodeType: InfraNodeType; +export interface InfraSnapshotRequestOptions + extends Omit { sourceConfiguration: InfraSourceConfiguration; - timerange: InfraTimerangeInput; - groupBy: InfraSnapshotGroupbyInput[]; - metric: InfraSnapshotMetricInput; filterQuery: JsonObject | undefined; - accountId?: string; - region?: string; } diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts index 4ff0df30abedd..751e494164756 100644 --- a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts @@ -8,7 +8,6 @@ import { first, set, startsWith } from 'lodash'; import { RequestHandlerContext } from 'src/core/server'; import { KibanaFramework } from '../../../lib/adapters/framework/kibana_framework_adapter'; import { InfraSourceConfiguration } from '../../../lib/sources'; -import { InfraNodeType } from '../../../graphql/types'; import { InfraMetadataInfo } from '../../../../common/http_api/metadata_api'; import { getPodNodeName } from './get_pod_node_name'; import { CLOUD_METRICS_MODULES } from '../../../lib/constants'; @@ -27,7 +26,7 @@ export const getNodeInfo = async ( // can report pod details and we can't rely on the host/cloud information associated // with the kubernetes.pod.uid. We need to first lookup the `kubernetes.node.name` // then use that to lookup the host's node information. - if (nodeType === InfraNodeType.pod) { + if (nodeType === 'pod') { const kubernetesNodeName = await getPodNodeName( framework, requestContext, @@ -41,7 +40,7 @@ export const getNodeInfo = async ( requestContext, sourceConfiguration, kubernetesNodeName, - InfraNodeType.host + 'host' ); } return {}; diff --git a/x-pack/legacy/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts b/x-pack/legacy/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts index 9e5fe16d482b2..7e47f50c76088 100644 --- a/x-pack/legacy/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts +++ b/x-pack/legacy/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts @@ -6,11 +6,10 @@ import { InfraMetricModelMetricType } from '../../../lib/adapters/metrics'; import { MetricsExplorerRequestBody } from '../types'; -import { InfraMetric } from '../../../graphql/types'; import { TSVBMetricModel } from '../../../../common/inventory_models/types'; export const createMetricModel = (options: MetricsExplorerRequestBody): TSVBMetricModel => { return { - id: InfraMetric.custom, + id: 'custom', requires: [], index_pattern: options.indexPattern, interval: options.timerange.interval, diff --git a/x-pack/legacy/plugins/infra/server/routes/node_details/index.ts b/x-pack/legacy/plugins/infra/server/routes/node_details/index.ts index a9419cd27e684..4a09615f0a17c 100644 --- a/x-pack/legacy/plugins/infra/server/routes/node_details/index.ts +++ b/x-pack/legacy/plugins/infra/server/routes/node_details/index.ts @@ -11,7 +11,7 @@ import { identity } from 'fp-ts/lib/function'; import { InfraBackendLibs } from '../../lib/infra_types'; import { UsageCollector } from '../../usage/usage_collector'; import { InfraMetricsRequestOptions } from '../../lib/adapters/metrics'; -import { InfraNodeType, InfraMetric } from '../../graphql/types'; + import { NodeDetailsRequestRT, NodeDetailsMetricDataResponseRT, @@ -46,9 +46,9 @@ export const initNodeDetailsRoute = (libs: InfraBackendLibs) => { nodeId, cloudId, }, - nodeType: nodeType as InfraNodeType, + nodeType, sourceConfiguration: source.configuration, - metrics: metrics as InfraMetric[], + metrics, timerange, }; return response.ok({ diff --git a/x-pack/legacy/plugins/infra/server/routes/snapshot/index.ts b/x-pack/legacy/plugins/infra/server/routes/snapshot/index.ts index ba7f52e9ec1e7..5f28e41d80c25 100644 --- a/x-pack/legacy/plugins/infra/server/routes/snapshot/index.ts +++ b/x-pack/legacy/plugins/infra/server/routes/snapshot/index.ts @@ -11,10 +11,8 @@ import { identity } from 'fp-ts/lib/function'; import { InfraBackendLibs } from '../../lib/infra_types'; import { UsageCollector } from '../../usage/usage_collector'; import { parseFilterQuery } from '../../utils/serialized_query'; -import { InfraNodeType, InfraSnapshotMetricInput } from '../../../public/graphql/types'; import { SnapshotRequestRT, SnapshotNodeResponseRT } from '../../../common/http_api/snapshot_api'; import { throwErrors } from '../../../common/runtime_types'; -import { InfraSnapshotRequestOptions } from '../../lib/snapshot/types'; const escapeHatch = schema.object({}, { allowUnknowns: true }); @@ -46,16 +44,14 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { ); const source = await libs.sources.getSourceConfiguration(requestContext, sourceId); UsageCollector.countNode(nodeType); - const options: InfraSnapshotRequestOptions = { + const options = { filterQuery: parseFilterQuery(filterQuery), accountId, region, - // TODO: Use common infra metric and replace graphql type - nodeType: nodeType as InfraNodeType, + nodeType, groupBy, sourceConfiguration: source.configuration, - // TODO: Use common infra metric and replace graphql type - metric: metric as InfraSnapshotMetricInput, + metric, timerange, }; const nodesWithInterval = await libs.snapshot.getNodes(requestContext, options); diff --git a/x-pack/legacy/plugins/infra/server/usage/usage_collector.ts b/x-pack/legacy/plugins/infra/server/usage/usage_collector.ts index 60b9372b135df..26578bfd2b794 100644 --- a/x-pack/legacy/plugins/infra/server/usage/usage_collector.ts +++ b/x-pack/legacy/plugins/infra/server/usage/usage_collector.ts @@ -5,7 +5,6 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { InfraNodeType } from '../graphql/types'; import { InventoryItemType } from '../../common/inventory_models/types'; const KIBANA_REPORTING_TYPE = 'infraops'; @@ -38,10 +37,10 @@ export class UsageCollector { this.maybeInitializeBucket(bucket); switch (nodeType) { - case InfraNodeType.pod: + case 'pod': this.counters[bucket].infraopsKubernetes += 1; break; - case InfraNodeType.container: + case 'container': this.counters[bucket].infraopsDocker += 1; break; default: diff --git a/x-pack/legacy/plugins/infra/server/utils/calculate_metric_interval.ts b/x-pack/legacy/plugins/infra/server/utils/calculate_metric_interval.ts index 586193a3c242d..7cbbdc0f2145b 100644 --- a/x-pack/legacy/plugins/infra/server/utils/calculate_metric_interval.ts +++ b/x-pack/legacy/plugins/infra/server/utils/calculate_metric_interval.ts @@ -5,9 +5,9 @@ */ import { RequestHandlerContext } from 'src/core/server'; -import { InfraNodeType } from '../graphql/types'; import { findInventoryModel } from '../../common/inventory_models'; import { KibanaFramework } from '../lib/adapters/framework/kibana_framework_adapter'; +import { InventoryItemType } from '../../common/inventory_models/types'; interface Options { indexPattern: string; @@ -27,7 +27,7 @@ export const calculateMetricInterval = async ( requestContext: RequestHandlerContext, options: Options, modules?: string[], - nodeType?: InfraNodeType // TODO: check that this type still makes sense + nodeType?: InventoryItemType // TODO: check that this type still makes sense ) => { let from = options.timerange.from; if (nodeType) { From 5853360d75ee17cd00c777d2b2c28fd2f8abd727 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Thu, 9 Jan 2020 18:14:53 -0500 Subject: [PATCH 55/63] pass more alert info into alert executor (#54035) resolves https://github.com/elastic/kibana/issues/50522 The alert executor function is now passed these additional alert-specific properties as parameters: - spaceId - namespace - name - tags - createdBy - updatedBy --- x-pack/legacy/plugins/alerting/README.md | 7 ++ .../server/task_runner/task_runner.test.ts | 8 ++ .../server/task_runner/task_runner.ts | 33 ++++++- .../legacy/plugins/alerting/server/types.ts | 18 ++++ .../common/fixtures/plugins/alerts/index.ts | 16 +++- .../common/lib/alert_utils.ts | 91 +++++++++++++------ .../alerting_api_integration/common/types.ts | 1 + .../security_and_spaces/scenarios.ts | 2 +- .../tests/alerting/alerts.ts | 65 ++++++++++++- .../spaces_only/scenarios.ts | 10 ++ .../alerting/{alerts.ts => alerts_base.ts} | 48 ++++++---- .../tests/alerting/alerts_default_space.ts | 14 +++ .../tests/alerting/alerts_space1.ts | 14 +++ .../spaces_only/tests/alerting/index.ts | 3 +- .../spaces_only/tests/index.ts | 5 +- 15 files changed, 281 insertions(+), 54 deletions(-) rename x-pack/test/alerting_api_integration/spaces_only/tests/alerting/{alerts.ts => alerts_base.ts} (88%) create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_default_space.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_space1.ts diff --git a/x-pack/legacy/plugins/alerting/README.md b/x-pack/legacy/plugins/alerting/README.md index 33679cf6fa422..30d34bd3b436d 100644 --- a/x-pack/legacy/plugins/alerting/README.md +++ b/x-pack/legacy/plugins/alerting/README.md @@ -63,6 +63,13 @@ This is the primary function for an alert type. Whenever the alert needs to exec |previousStartedAt|The previous date and time the alert type started a successful execution.| |params|Parameters for the execution. This is where the parameters you require will be passed in. (example threshold). Use alert type validation to ensure values are set before execution.| |state|State returned from previous execution. This is the alert level state. What the executor returns will be serialized and provided here at the next execution.| +|alertId|The id of this alert.| +|spaceId|The id of the space of this alert.| +|namespace|The namespace of the space of this alert; same as spaceId, unless spaceId === 'default', then namespace = undefined.| +|name|The name of this alert.| +|tags|The tags associated with this alert.| +|createdBy|The userid that created this alert.| +|updatedBy|The userid that last updated this alert.| ### Example diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts index 10627c655eca8..87fa33a9cea58 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts @@ -75,6 +75,10 @@ describe('Task Runner', () => { enabled: true, alertTypeId: '123', schedule: { interval: '10s' }, + name: 'alert-name', + tags: ['alert-', '-tags'], + createdBy: 'alert-creator', + updatedBy: 'alert-updater', mutedInstanceIds: [], params: { bar: true, @@ -138,6 +142,10 @@ describe('Task Runner', () => { `); expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); expect(call.state).toMatchInlineSnapshot(`Object {}`); + expect(call.name).toBe('alert-name'); + expect(call.tags).toEqual(['alert-', '-tags']); + expect(call.createdBy).toBe('alert-creator'); + expect(call.updatedBy).toBe('alert-updater'); expect(call.services.alertInstanceFactory).toBeTruthy(); expect(call.services.callCluster).toBeTruthy(); expect(call.services).toBeTruthy(); diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts index 2347e9e608ed9..42c332e82e034 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -13,7 +13,7 @@ import { createExecutionHandler } from './create_execution_handler'; import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; import { getNextRunAt } from './get_next_run_at'; import { validateAlertTypeParams } from '../lib'; -import { AlertType, RawAlert, IntervalSchedule, Services, State } from '../types'; +import { AlertType, RawAlert, IntervalSchedule, Services, State, AlertInfoParams } from '../types'; import { promiseResult, map } from '../lib/result_type'; type AlertInstances = Record; @@ -118,13 +118,25 @@ export class TaskRunner { async executeAlertInstances( services: Services, - { params, throttle, muteAll, mutedInstanceIds }: SavedObject['attributes'], - executionHandler: ReturnType + alertInfoParams: AlertInfoParams, + executionHandler: ReturnType, + spaceId: string ): Promise { + const { + params, + throttle, + muteAll, + mutedInstanceIds, + name, + tags, + createdBy, + updatedBy, + } = alertInfoParams; const { params: { alertId }, state: { alertInstances: alertRawInstances = {}, alertTypeState = {}, previousStartedAt }, } = this.taskInstance; + const namespace = this.context.spaceIdToNamespace(spaceId); const alertInstances = mapValues( alertRawInstances, @@ -141,6 +153,12 @@ export class TaskRunner { state: alertTypeState, startedAt: this.taskInstance.startedAt!, previousStartedAt, + spaceId, + namespace, + name, + tags, + createdBy, + updatedBy, }); // Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object @@ -175,7 +193,7 @@ export class TaskRunner { async validateAndRunAlert( services: Services, apiKey: string | null, - attributes: SavedObject['attributes'], + attributes: RawAlert, references: SavedObject['references'] ) { const { @@ -191,7 +209,12 @@ export class TaskRunner { attributes.actions, references ); - return this.executeAlertInstances(services, { ...attributes, params }, executionHandler); + return this.executeAlertInstances( + services, + { ...attributes, params }, + executionHandler, + spaceId + ); } async run() { diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts index 9b03f9b02aa0a..def86cd46e590 100644 --- a/x-pack/legacy/plugins/alerting/server/types.ts +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -32,6 +32,12 @@ export interface AlertExecutorOptions { services: AlertServices; params: Record; state: State; + spaceId: string; + namespace?: string; + name: string; + tags: string[]; + createdBy: string | null; + updatedBy: string | null; } export interface AlertType { @@ -108,6 +114,18 @@ export interface RawAlert extends SavedObjectAttributes { mutedInstanceIds: string[]; } +export type AlertInfoParams = Pick< + RawAlert, + | 'params' + | 'throttle' + | 'muteAll' + | 'mutedInstanceIds' + | 'name' + | 'tags' + | 'createdBy' + | 'updatedBy' +>; + export interface AlertingPlugin { setup: PluginSetupContract; start: PluginStartContract; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts index ebe741df71d79..b5d201c1682bd 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts @@ -202,8 +202,21 @@ export default function(kibana: any) { id: 'test.always-firing', name: 'Test: Always Firing', actionGroups: ['default', 'other'], - async executor({ services, params, state }: AlertExecutorOptions) { + async executor(alertExecutorOptions: AlertExecutorOptions) { + const { + services, + params, + state, + alertId, + spaceId, + namespace, + name, + tags, + createdBy, + updatedBy, + } = alertExecutorOptions; let group = 'default'; + const alertInfo = { alertId, spaceId, namespace, name, tags, createdBy, updatedBy }; if (params.groupsToScheduleActionsInSeries) { const index = state.groupInSeriesIndex || 0; @@ -226,6 +239,7 @@ export default function(kibana: any) { params, reference: params.reference, source: 'alert:test.always-firing', + alertInfo, }, }); return { diff --git a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts index c47649544f9a7..c793af359489a 100644 --- a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts @@ -24,6 +24,14 @@ export interface CreateAlertWithActionOpts { reference: string; } +interface UpdateAlwaysFiringAction { + alertId: string; + actionId: string | undefined; + reference: string; + user: User; + overwrites: Record; +} + export class AlertUtils { private referenceCounter = 1; private readonly user?: User; @@ -176,38 +184,41 @@ export class AlertUtils { if (this.user) { request = request.auth(this.user.username, this.user.password); } - const response = await request.send({ - enabled: true, - name: 'abc', - schedule: { interval: '1m' }, - throttle: '1m', - tags: [], - alertTypeId: 'test.always-firing', - consumer: 'bar', - params: { - index: ES_TEST_INDEX_NAME, - reference, - }, - actions: [ - { - group: 'default', - id: this.indexRecordActionId, - params: { - index: ES_TEST_INDEX_NAME, - reference, - message: - 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - ...overwrites, - }); + const alertBody = getDefaultAlwaysFiringAlertData(reference, actionId); + const response = await request.send({ ...alertBody, ...overwrites }); if (response.statusCode === 200) { objRemover.add(this.space.id, response.body.id, 'alert'); } return response; } + public async updateAlwaysFiringAction({ + alertId, + actionId, + reference, + user, + overwrites = {}, + }: UpdateAlwaysFiringAction) { + actionId = actionId || this.indexRecordActionId; + + if (!actionId) { + throw new Error('actionId is required '); + } + + const request = this.supertestWithoutAuth + .put(`${getUrlPrefix(this.space.id)}/api/alert/${alertId}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + const alertBody = getDefaultAlwaysFiringAlertData(reference, actionId); + delete alertBody.alertTypeId; + delete alertBody.enabled; + delete alertBody.consumer; + + const response = await request.send({ ...alertBody, ...overwrites }); + return response; + } + public async createAlwaysFailingAction({ objectRemover, overwrites = {}, @@ -251,3 +262,31 @@ export class AlertUtils { return response; } } + +function getDefaultAlwaysFiringAlertData(reference: string, actionId: string) { + return { + enabled: true, + name: 'abc', + schedule: { interval: '1m' }, + throttle: '1m', + tags: [], + alertTypeId: 'test.always-firing', + consumer: 'bar', + params: { + index: ES_TEST_INDEX_NAME, + reference, + }, + actions: [ + { + group: 'default', + id: actionId, + params: { + index: ES_TEST_INDEX_NAME, + reference, + message: + 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', + }, + }, + ], + }; +} diff --git a/x-pack/test/alerting_api_integration/common/types.ts b/x-pack/test/alerting_api_integration/common/types.ts index e94add5bbcd28..c4a341435aaaa 100644 --- a/x-pack/test/alerting_api_integration/common/types.ts +++ b/x-pack/test/alerting_api_integration/common/types.ts @@ -52,6 +52,7 @@ export interface User { export interface Space { id: string; + namespace?: string; name: string; disabledFeatures: string[]; } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts index 354d87bd11bb2..d58fcd29e29fc 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts @@ -29,7 +29,7 @@ const NoKibanaPrivileges: User = { }, }; -const Superuser: User = { +export const Superuser: User = { username: 'superuser', fullName: 'superuser', password: 'superuser-password', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 69bc547e3bfc1..551498e22d5c8 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { UserAtSpaceScenarios } from '../../scenarios'; +import { UserAtSpaceScenarios, Superuser } from '../../scenarios'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { ESTestIndexTool, @@ -96,7 +96,9 @@ export default function alertTests({ getService }: FtrProviderContext) { // Wait for the action to index a document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.index-record', reference); - await alertUtils.disable(response.body.id); + + const alertId = response.body.id; + await alertUtils.disable(alertId); await taskManagerUtils.waitForIdle(testStart); // Ensure only 1 alert executed with proper params @@ -113,6 +115,15 @@ export default function alertTests({ getService }: FtrProviderContext) { index: ES_TEST_INDEX_NAME, reference, }, + alertInfo: { + alertId, + spaceId: space.id, + namespace: space.id, + name: 'abc', + tags: [], + createdBy: user.fullName, + updatedBy: user.fullName, + }, }); // Ensure only 1 action executed with proper params @@ -142,6 +153,56 @@ export default function alertTests({ getService }: FtrProviderContext) { } }); + it('should pass updated alert params to executor', async () => { + // create an alert + const reference = alertUtils.generateReference(); + const overwrites = { + throttle: '1s', + schedule: { interval: '1s' }, + }; + const response = await alertUtils.createAlwaysFiringAction({ reference, overwrites }); + + // only need to test creation success paths + if (response.statusCode !== 200) return; + + // update the alert with super user + const alertId = response.body.id; + const reference2 = alertUtils.generateReference(); + const response2 = await alertUtils.updateAlwaysFiringAction({ + alertId, + actionId: indexRecordActionId, + user: Superuser, + reference: reference2, + overwrites: { + name: 'def', + tags: ['fee', 'fi', 'fo'], + throttle: '1s', + schedule: { interval: '1s' }, + }, + }); + + expect(response2.statusCode).to.eql(200); + + // make sure alert info passed to executor is correct + await esTestIndexTool.waitForDocs('alert:test.always-firing', reference2); + await alertUtils.disable(alertId); + const alertSearchResult = await esTestIndexTool.search( + 'alert:test.always-firing', + reference2 + ); + + expect(alertSearchResult.hits.total.value).to.be.greaterThan(0); + expect(alertSearchResult.hits.hits[0]._source.alertInfo).to.eql({ + alertId, + spaceId: space.id, + namespace: space.id, + name: 'def', + tags: ['fee', 'fi', 'fo'], + createdBy: user.fullName, + updatedBy: Superuser.fullName, + }); + }); + it('should handle custom retry logic when appropriate', async () => { const testStart = new Date(); // We have to provide the test.rate-limit the next runAt, for testing purposes diff --git a/x-pack/test/alerting_api_integration/spaces_only/scenarios.ts b/x-pack/test/alerting_api_integration/spaces_only/scenarios.ts index 2b91c408d3de9..c2b3ec6148036 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/scenarios.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/scenarios.ts @@ -8,17 +8,27 @@ import { Space } from '../common/types'; const Space1: Space = { id: 'space1', + namespace: 'space1', name: 'Space 1', disabledFeatures: [], }; const Other: Space = { id: 'other', + namespace: 'other', name: 'Other', disabledFeatures: [], }; +const Default: Space = { + id: 'default', + namespace: undefined, + name: 'Default', + disabledFeatures: [], +}; + export const Spaces = { space1: Space1, other: Other, + default: Default, }; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts similarity index 88% rename from x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts rename to x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts index 032fee15882cf..d9a58851afb31 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { Response as SupertestResponse } from 'supertest'; -import { Spaces } from '../../scenarios'; +import { Space } from '../../../common/types'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { ESTestIndexTool, @@ -19,7 +19,7 @@ import { } from '../../../common/lib'; // eslint-disable-next-line import/no-default-export -export default function alertTests({ getService }: FtrProviderContext) { +export function alertTests({ getService }: FtrProviderContext, space: Space) { const supertestWithoutAuth = getService('supertestWithoutAuth'); const es = getService('legacyEs'); const retry = getService('retry'); @@ -43,7 +43,7 @@ export default function alertTests({ getService }: FtrProviderContext) { await esTestIndexTool.setup(); await es.indices.create({ index: authorizationIndex }); const { body: createdAction } = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/action`) + .post(`${getUrlPrefix(space.id)}/api/action`) .set('kbn-xsrf', 'foo') .send({ name: 'My action', @@ -58,7 +58,7 @@ export default function alertTests({ getService }: FtrProviderContext) { .expect(200); indexRecordActionId = createdAction.id; alertUtils = new AlertUtils({ - space: Spaces.space1, + space, supertestWithoutAuth, indexRecordActionId, objectRemover, @@ -68,19 +68,20 @@ export default function alertTests({ getService }: FtrProviderContext) { after(async () => { await esTestIndexTool.destroy(); await es.indices.delete({ index: authorizationIndex }); - objectRemover.add(Spaces.space1.id, indexRecordActionId, 'action'); + objectRemover.add(space.id, indexRecordActionId, 'action'); await objectRemover.removeAll(); }); it('should schedule task, run alert and schedule actions', async () => { const reference = alertUtils.generateReference(); const response = await alertUtils.createAlwaysFiringAction({ reference }); + const alertId = response.body.id; expect(response.statusCode).to.eql(200); const alertTestRecord = ( await esTestIndexTool.waitForDocs('alert:test.always-firing', reference) )[0]; - expect(alertTestRecord._source).to.eql({ + const expected = { source: 'alert:test.always-firing', reference, state: {}, @@ -88,7 +89,20 @@ export default function alertTests({ getService }: FtrProviderContext) { index: ES_TEST_INDEX_NAME, reference, }, - }); + alertInfo: { + alertId, + spaceId: space.id, + namespace: space.namespace, + name: 'abc', + tags: [], + createdBy: null, + updatedBy: null, + }, + }; + if (expected.alertInfo.namespace === undefined) { + delete expected.alertInfo.namespace; + } + expect(alertTestRecord._source).to.eql(expected); const actionTestRecord = ( await esTestIndexTool.waitForDocs('action:test.index-record', reference) )[0]; @@ -147,7 +161,7 @@ export default function alertTests({ getService }: FtrProviderContext) { const retryDate = new Date(Date.now() + 60000); const { body: createdAction } = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/action`) + .post(`${getUrlPrefix(space.id)}/api/action`) .set('kbn-xsrf', 'foo') .send({ name: 'Test rate limit', @@ -155,11 +169,11 @@ export default function alertTests({ getService }: FtrProviderContext) { config: {}, }) .expect(200); - objectRemover.add(Spaces.space1.id, createdAction.id, 'action'); + objectRemover.add(space.id, createdAction.id, 'action'); const reference = alertUtils.generateReference(); const response = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`) + .post(`${getUrlPrefix(space.id)}/api/alert`) .set('kbn-xsrf', 'foo') .send( getTestAlertData({ @@ -184,7 +198,7 @@ export default function alertTests({ getService }: FtrProviderContext) { ); expect(response.statusCode).to.eql(200); - objectRemover.add(Spaces.space1.id, response.body.id, 'alert'); + objectRemover.add(space.id, response.body.id, 'alert'); const scheduledActionTask = await retry.try(async () => { const searchResult = await es.search({ index: '.kibana_task_manager', @@ -228,7 +242,7 @@ export default function alertTests({ getService }: FtrProviderContext) { it('should have proper callCluster and savedObjectsClient authorization for alert type executor', async () => { const reference = alertUtils.generateReference(); const response = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`) + .post(`${getUrlPrefix(space.id)}/api/alert`) .set('kbn-xsrf', 'foo') .send( getTestAlertData({ @@ -244,7 +258,7 @@ export default function alertTests({ getService }: FtrProviderContext) { ); expect(response.statusCode).to.eql(200); - objectRemover.add(Spaces.space1.id, response.body.id, 'alert'); + objectRemover.add(space.id, response.body.id, 'alert'); const alertTestRecord = ( await esTestIndexTool.waitForDocs('alert:test.authorization', reference) )[0]; @@ -264,16 +278,16 @@ export default function alertTests({ getService }: FtrProviderContext) { it('should have proper callCluster and savedObjectsClient authorization for action type executor', async () => { const reference = alertUtils.generateReference(); const { body: createdAction } = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/action`) + .post(`${getUrlPrefix(space.id)}/api/action`) .set('kbn-xsrf', 'foo') .send({ name: 'My action', actionTypeId: 'test.authorization', }) .expect(200); - objectRemover.add(Spaces.space1.id, createdAction.id, 'action'); + objectRemover.add(space.id, createdAction.id, 'action'); const response = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`) + .post(`${getUrlPrefix(space.id)}/api/alert`) .set('kbn-xsrf', 'foo') .send( getTestAlertData({ @@ -299,7 +313,7 @@ export default function alertTests({ getService }: FtrProviderContext) { ); expect(response.statusCode).to.eql(200); - objectRemover.add(Spaces.space1.id, response.body.id, 'alert'); + objectRemover.add(space.id, response.body.id, 'alert'); const actionTestRecord = ( await esTestIndexTool.waitForDocs('action:test.authorization', reference) )[0]; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_default_space.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_default_space.ts new file mode 100644 index 0000000000000..3e677952d8700 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_default_space.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 { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { Spaces } from '../../scenarios'; +import { alertTests } from './alerts_base'; + +// eslint-disable-next-line import/no-default-export +export default function alertSpace1Tests(context: FtrProviderContext) { + alertTests(context, Spaces.default); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_space1.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_space1.ts new file mode 100644 index 0000000000000..07ad4cd294ab3 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_space1.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 { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { Spaces } from '../../scenarios'; +import { alertTests } from './alerts_base'; + +// eslint-disable-next-line import/no-default-export +export default function alertSpace1Tests(context: FtrProviderContext) { + alertTests(context, Spaces.space1); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index 1aa084356cfa4..569c0d538d473 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -22,6 +22,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./unmute_instance')); loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./update_api_key')); - loadTestFile(require.resolve('./alerts')); + loadTestFile(require.resolve('./alerts_space1')); + loadTestFile(require.resolve('./alerts_default_space')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts index f18aebaf4e689..cb2b17980d37a 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts @@ -20,7 +20,10 @@ export default function alertingApiIntegrationTests({ before(async () => { for (const space of Object.values(Spaces)) { - await spacesService.create(space); + if (space.id === 'default') continue; + + const { id, name, disabledFeatures } = space; + await spacesService.create({ id, name, disabledFeatures }); } }); From 299df2dae7201a766de15f0b369be2f40c1d37c9 Mon Sep 17 00:00:00 2001 From: debadair Date: Thu, 9 Jan 2020 16:12:15 -0800 Subject: [PATCH 56/63] [DOCS] Updated links to snapshot-restore docs. (#52151) --- docs/management/snapshot-restore/index.asciidoc | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/management/snapshot-restore/index.asciidoc b/docs/management/snapshot-restore/index.asciidoc index f19aaa122675e..dc722c24af76c 100644 --- a/docs/management/snapshot-restore/index.asciidoc +++ b/docs/management/snapshot-restore/index.asciidoc @@ -21,7 +21,7 @@ With this UI, you can: image:management/snapshot-restore/images/snapshot_list.png["Snapshot list"] Before using this feature, you should be familiar with how snapshots work. -{ref}/modules-snapshots.html[Snapshot and Restore] is a good source for +{ref}/snapshot-restore.html[Snapshot and Restore] is a good source for more detailed information. [float] @@ -35,9 +35,9 @@ registering one. {kib} supports three repository types out of the box: shared file system, read-only URL, and source-only. For more information on these repositories and their settings, -see {ref}/modules-snapshots.html#snapshots-repositories[Repositories]. +see {ref}/snapshots-register-repository.html[Repositories]. To use other repositories, such as S3, see -{ref}/modules-snapshots.html#_repository_plugins[Repository plugins]. +{ref}/snapshots-register-repository.html#snapshots-repository-plugins[Repository plugins]. Once you create a repository, it is listed in the *Repositories* @@ -61,7 +61,7 @@ into each snapshot for further investigation. image:management/snapshot-restore/images/snapshot_details.png["Snapshot details"] If you don’t have any snapshots, you can create them from the {kib} <>. The -{ref}//modules-snapshots.html#snapshots-take-snapshot[snapshot API] +{ref}/snapshots-take-snapshot.html[snapshot API] takes the current state and data in your index or cluster, and then saves it to a shared repository. @@ -162,7 +162,7 @@ Ready to try *Snapshot and Restore*? In this tutorial, you'll learn to: This example shows you how to register a shared file system repository and store snapshots. Before you begin, you must register the location of the repository in the -{ref}/modules-snapshots.html#_shared_file_system_repository[path.repo] setting on +{ref}/snapshots-register-repository.html#snapshots-filesystem-repository[path.repo] setting on your master and data nodes. You can do this in one of two ways: * Edit your `elasticsearch.yml` to include the `path.repo` setting. @@ -197,7 +197,7 @@ The repository currently doesn’t have any snapshots. [float] ==== Add a snapshot to the repository -Use the {ref}//modules-snapshots.html#snapshots-take-snapshot[snapshot API] to create a snapshot. +Use the {ref}/snapshots-take-snapshot.html[snapshot API] to create a snapshot. . Go to *Dev Tools > Console*. . Create the snapshot: @@ -206,7 +206,7 @@ Use the {ref}//modules-snapshots.html#snapshots-take-snapshot[snapshot API] to c PUT /_snapshot/my_backup/2019-04-25_snapshot?wait_for_completion=true + In this example, the snapshot name is `2019-04-25_snapshot`. You can also -use {ref}//date-math-index-names.html[date math expression] for the snapshot name. +use {ref}/date-math-index-names.html[date math expression] for the snapshot name. + [role="screenshot"] image:management/snapshot-restore/images/create_snapshot.png["Create snapshot"] From 7eb88c4d132edd4209d7184a3c185cf45d034891 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Thu, 9 Jan 2020 17:16:45 -0700 Subject: [PATCH 57/63] [SIEM][Detection Engine] Import/Export REST endpoints (#54332) ## Summary * Adds Import and Export REST endpoints * Fixes minor misc issues with types * Changes camel case from bulk api to become snake_case For the API and testing it is very similar to the saved objects API For import: ```ts POST /api/detection_engine/rules/_import ``` With a ndjson body of: ```ts {"created_at":"2020-01-09T01:38:00.740Z","updated_at":"2020-01-09T01:38:00.740Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"6688f367-1aa2-4895-a5a8-b3701eecf57d","immutable":false,"interval":"5m","rule_id":"query-rule-id-1","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":1,"name":"Query with a rule id Number 1","query":"user.name: root or user.name: admin","references":[],"severity":"high","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1} {"created_at":"2020-01-09T01:38:00.745Z","updated_at":"2020-01-09T01:38:00.745Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"7a912444-6cfa-4c8f-83f4-2b26fb2a2ed9","immutable":false,"interval":"5m","rule_id":"query-rule-id-2","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":2,"name":"Query with a rule id Number 2","query":"user.name: root or user.name: admin","references":[],"severity":"low","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1} {"exported_count":2,"missing_rules":[],"missing_rules_count":0} ``` If you want to overwrite existing objects you can use the overwrite query parameter like so: ```ts POST /api/detection_engine/rules/_import?overwrite=true ``` See and run the scripts of: ```ts import_rules.sh import_rules_no_overwrite.sh ``` For exporting everything: ```ts POST /api/detection_engine/rules/_export ``` For exporting just a handful of things you would send a body like so: ```ts POST /api/detection_engine/rules/_export { "objects": [ { "rule_id": "query-rule-id-1" }, { "rule_id": "query-rule-id-2" } ] } ``` To change either the filename of the file that gets downloaded or to remove the extra appended export details you can do the following: ```ts POST /api/detection_engine/rules/_export?exclude_export_details=true&file_name=my_file.ndjson" ``` See the scripts of: ```ts export_rules.sh export_rules_by_rule_id.sh export_rules_by_rule_id_to_file.sh export_rules_to_file.sh ``` ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. ~~- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)~~ ~~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~~ ~~- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~~ - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios ~~- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~~ ### For maintainers ~~- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~ - [x] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process) --- .../plugins/siem/server/kibana.index.ts | 4 + .../routes/__mocks__/request_responses.ts | 4 +- .../rules/delete_rules_bulk_route.test.ts | 13 +- .../routes/rules/export_rules_route.ts | 70 + .../routes/rules/import_rules_route.ts | 213 +++ .../routes/rules/update_rules_bulk.test.ts | 25 +- .../routes/rules/utils.test.ts | 313 +++- .../detection_engine/routes/rules/utils.ts | 40 +- .../schemas/export_rules_schema.test.ts | 99 ++ .../routes/schemas/export_rules_schema.ts | 22 + .../schemas/import_rules_schema.test.ts | 1327 +++++++++++++++++ .../routes/schemas/import_rules_schema.ts | 100 ++ .../routes/schemas/schemas.ts | 17 +- .../lib/detection_engine/routes/utils.test.ts | 186 ++- .../lib/detection_engine/routes/utils.ts | 89 +- .../create_rules_stream_from_ndjson.test.ts | 375 +++++ .../rules/create_rules_stream_from_ndjson.ts | 88 ++ .../lib/detection_engine/rules/find_rules.ts | 12 +- .../get_existing_prepackaged_rules.test.ts | 233 ++- .../rules/get_existing_prepackaged_rules.ts | 64 +- .../rules/get_export_all.test.ts | 49 + .../detection_engine/rules/get_export_all.ts | 23 + .../rules/get_export_by_object_ids.test.ts | 173 +++ .../rules/get_export_by_object_ids.ts | 60 + .../rules/get_export_details_ndjson.test.ts | 56 + .../rules/get_export_details_ndjson.ts | 19 + .../lib/detection_engine/rules/read_rules.ts | 5 +- .../lib/detection_engine/rules/types.ts | 19 + .../detection_engine/scripts/export_rules.sh | 25 + .../scripts/export_rules_by_rule_id.sh | 29 + .../export_rules_by_rule_id_to_file.sh | 27 + .../scripts/export_rules_to_file.sh | 23 + .../detection_engine/scripts/import_rules.sh | 26 + .../scripts/import_rules_no_overwrite.sh | 21 + .../scripts/rules/export/ruleid_queries.json | 10 + .../import/multiple_ruleid_queries.ndjson | 3 + .../rules/queries/query_with_everything.json | 2 +- .../multiple_ruleid_queries_corrupted.ndjson | 4 + .../siem/server/lib/detection_engine/types.ts | 5 + 39 files changed, 3805 insertions(+), 68 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.ts create mode 100755 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules.sh create mode 100755 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id.sh create mode 100755 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id_to_file.sh create mode 100755 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_to_file.sh create mode 100755 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules.sh create mode 100755 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules_no_overwrite.sh create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/export/ruleid_queries.json create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/import/multiple_ruleid_queries.ndjson create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/test_cases/multiple_ruleid_queries_corrupted.ndjson diff --git a/x-pack/legacy/plugins/siem/server/kibana.index.ts b/x-pack/legacy/plugins/siem/server/kibana.index.ts index 59df643246835..7060a3f662914 100644 --- a/x-pack/legacy/plugins/siem/server/kibana.index.ts +++ b/x-pack/legacy/plugins/siem/server/kibana.index.ts @@ -25,6 +25,8 @@ import { addPrepackedRulesRoute } from './lib/detection_engine/routes/rules/add_ import { createRulesBulkRoute } from './lib/detection_engine/routes/rules/create_rules_bulk_route'; import { updateRulesBulkRoute } from './lib/detection_engine/routes/rules/update_rules_bulk_route'; import { deleteRulesBulkRoute } from './lib/detection_engine/routes/rules/delete_rules_bulk_route'; +import { importRulesRoute } from './lib/detection_engine/routes/rules/import_rules_route'; +import { exportRulesRoute } from './lib/detection_engine/routes/rules/export_rules_route'; const APP_ID = 'siem'; @@ -50,6 +52,8 @@ export const initServerWithKibana = (context: PluginInitializerContext, __legacy createRulesBulkRoute(__legacy); updateRulesBulkRoute(__legacy); deleteRulesBulkRoute(__legacy); + importRulesRoute(__legacy); + exportRulesRoute(__legacy); // Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals // POST /api/detection_engine/signals/status diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index f6ac0435cd7c1..8ed5333bd2a25 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -159,7 +159,7 @@ export const getPrivilegeRequest = (): ServerInjectOptions => ({ url: `${DETECTION_ENGINE_PRIVILEGES_URL}`, }); -interface FindHit { +export interface FindHit { page: number; perPage: number; total: number; @@ -176,7 +176,7 @@ export const getFindResult = (): FindHit => ({ export const getFindResultWithSingleHit = (): FindHit => ({ page: 1, perPage: 1, - total: 0, + total: 1, data: [getResult()], }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts index 11a076951fd8c..bab05b065f6f7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts @@ -24,6 +24,7 @@ import { import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { deleteRulesBulkRoute } from './delete_rules_bulk_route'; +import { BulkError } from '../utils'; describe('delete_rules', () => { let { server, alertsClient } = createMockServer(); @@ -83,10 +84,14 @@ describe('delete_rules', () => { alertsClient.get.mockResolvedValue(getResult()); alertsClient.delete.mockResolvedValue({}); const { payload } = await server.inject(getDeleteBulkRequest()); - const parsed = JSON.parse(payload); - expect(parsed).toEqual([ - { error: { message: 'rule_id: "rule-1" not found', statusCode: 404 }, id: 'rule-1' }, - ]); + const parsed: BulkError[] = JSON.parse(payload); + const expected: BulkError[] = [ + { + error: { message: 'rule_id: "rule-1" not found', status_code: 404 }, + rule_id: 'rule-1', + }, + ]; + expect(parsed).toEqual(expected); }); test('returns 404 if actionClient is not available on the route', async () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts new file mode 100644 index 0000000000000..aa17946849027 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import Hapi from 'hapi'; +import { isFunction } from 'lodash/fp'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { ExportRulesRequest } from '../../rules/types'; +import { ServerFacade } from '../../../../types'; +import { getNonPackagedRulesCount } from '../../rules/get_existing_prepackaged_rules'; +import { exportRulesSchema, exportRulesQuerySchema } from '../schemas/export_rules_schema'; +import { getExportByObjectIds } from '../../rules/get_export_by_object_ids'; +import { getExportAll } from '../../rules/get_export_all'; + +export const createExportRulesRoute = (server: ServerFacade): Hapi.ServerRoute => { + return { + method: 'POST', + path: `${DETECTION_ENGINE_RULES_URL}/_export`, + options: { + tags: ['access:siem'], + validate: { + options: { + abortEarly: false, + }, + payload: exportRulesSchema, + query: exportRulesQuerySchema, + }, + }, + async handler(request: ExportRulesRequest, headers) { + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const actionsClient = isFunction(request.getActionsClient) + ? request.getActionsClient() + : null; + + if (!alertsClient || !actionsClient) { + return headers.response().code(404); + } + + const exportSizeLimit = server.config().get('savedObjects.maxImportExportSize'); + if (request.payload?.objects != null && request.payload.objects.length > exportSizeLimit) { + return Boom.badRequest(`Can't export more than ${exportSizeLimit} rules`); + } else { + const nonPackagedRulesCount = await getNonPackagedRulesCount({ alertsClient }); + if (nonPackagedRulesCount > exportSizeLimit) { + return Boom.badRequest(`Can't export more than ${exportSizeLimit} rules`); + } + } + + const exported = + request.payload?.objects != null + ? await getExportByObjectIds(alertsClient, request.payload.objects) + : await getExportAll(alertsClient); + + const response = request.query.exclude_export_details + ? headers.response(exported.rulesNdjson) + : headers.response(`${exported.rulesNdjson}${exported.exportDetails}`); + + return response + .header('Content-Disposition', `attachment; filename="${request.query.file_name}"`) + .header('Content-Type', 'application/ndjson'); + }, + }; +}; + +export const exportRulesRoute = (server: ServerFacade): void => { + server.route(createExportRulesRoute(server)); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts new file mode 100644 index 0000000000000..e312b5fc6bb10 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -0,0 +1,213 @@ +/* + * 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 Boom from 'boom'; +import Hapi from 'hapi'; +import { extname } from 'path'; +import { isFunction } from 'lodash/fp'; +import { createPromiseFromStreams } from '../../../../../../../../../src/legacy/utils/streams'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { createRules } from '../../rules/create_rules'; +import { ImportRulesRequest } from '../../rules/types'; +import { ServerFacade } from '../../../../types'; +import { readRules } from '../../rules/read_rules'; +import { getIndexExists } from '../../index/get_index_exists'; +import { + callWithRequestFactory, + getIndex, + createImportErrorObject, + transformImportError, + ImportSuccessError, +} from '../utils'; +import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; +import { ImportRuleAlertRest } from '../../types'; +import { transformOrImportError } from './utils'; +import { updateRules } from '../../rules/update_rules'; +import { importRulesQuerySchema, importRulesPayloadSchema } from '../schemas/import_rules_schema'; + +export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute => { + return { + method: 'POST', + path: `${DETECTION_ENGINE_RULES_URL}/_import`, + options: { + tags: ['access:siem'], + payload: { + maxBytes: server.config().get('savedObjects.maxImportPayloadBytes'), + output: 'stream', + allow: 'multipart/form-data', + }, + validate: { + options: { + abortEarly: false, + }, + query: importRulesQuerySchema, + payload: importRulesPayloadSchema, + }, + }, + async handler(request: ImportRulesRequest, headers) { + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const actionsClient = isFunction(request.getActionsClient) + ? request.getActionsClient() + : null; + + if (!alertsClient || !actionsClient) { + return headers.response().code(404); + } + const { filename } = request.payload.file.hapi; + const fileExtension = extname(filename).toLowerCase(); + if (fileExtension !== '.ndjson') { + return Boom.badRequest(`Invalid file extension ${fileExtension}`); + } + + const objectLimit = server.config().get('savedObjects.maxImportExportSize'); + const readStream = createRulesStreamFromNdJson(request.payload.file, objectLimit); + const parsedObjects = await createPromiseFromStreams<[ImportRuleAlertRest | Error]>([ + readStream, + ]); + + const reduced = await parsedObjects.reduce>( + async (accum, parsedRule) => { + const existingImportSuccessError = await accum; + if (parsedRule instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + return createImportErrorObject({ + ruleId: '(unknown)', // TODO: Better handling where we know which ruleId is having issues with imports + statusCode: 400, + message: parsedRule.message, + existingImportSuccessError, + }); + } + + const { + description, + enabled, + false_positives: falsePositives, + from, + immutable, + query, + language, + output_index: outputIndex, + saved_id: savedId, + meta, + filters, + rule_id: ruleId, + index, + interval, + max_signals: maxSignals, + risk_score: riskScore, + name, + severity, + tags, + threats, + to, + type, + references, + timeline_id: timelineId, + timeline_title: timelineTitle, + version, + } = parsedRule; + try { + const finalIndex = outputIndex != null ? outputIndex : getIndex(request, server); + const callWithRequest = callWithRequestFactory(request, server); + const indexExists = await getIndexExists(callWithRequest, finalIndex); + if (!indexExists) { + return createImportErrorObject({ + ruleId, + statusCode: 409, + message: `To create a rule, the index must exist first. Index ${finalIndex} does not exist`, + existingImportSuccessError, + }); + } + const rule = await readRules({ alertsClient, ruleId }); + if (rule == null) { + const createdRule = await createRules({ + alertsClient, + actionsClient, + createdAt: new Date().toISOString(), + description, + enabled, + falsePositives, + from, + immutable, + query, + language, + outputIndex: finalIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threats, + updatedAt: new Date().toISOString(), + references, + version, + }); + return transformOrImportError(ruleId, createdRule, existingImportSuccessError); + } else if (rule != null && request.query.overwrite) { + const updatedRule = await updateRules({ + alertsClient, + actionsClient, + description, + enabled, + falsePositives, + from, + immutable, + query, + language, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + id: undefined, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threats, + references, + version, + }); + return transformOrImportError(ruleId, updatedRule, existingImportSuccessError); + } else { + return existingImportSuccessError; + } + } catch (err) { + return transformImportError(ruleId, err, existingImportSuccessError); + } + }, + Promise.resolve({ + success: true, + success_count: 0, + errors: [], + }) + ); + return reduced; + }, + }; +}; + +export const importRulesRoute = (server: ServerFacade): void => { + server.route(createImportRulesRoute(server)); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts index 9ae2941e6e5f2..2410dcee203f6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts @@ -23,6 +23,7 @@ import { } from '../__mocks__/request_responses'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { updateRulesBulkRoute } from './update_rules_bulk_route'; +import { BulkError } from '../utils'; describe('update_rules_bulk', () => { let { server, alertsClient, actionsClient } = createMockServer(); @@ -58,10 +59,14 @@ describe('update_rules_bulk', () => { actionsClient.update.mockResolvedValue(updateActionResult()); alertsClient.update.mockResolvedValue(getResult()); const { payload } = await server.inject(getUpdateBulkRequest()); - const parsed = JSON.parse(payload); - expect(parsed).toEqual([ - { error: { message: 'rule_id: "rule-1" not found', statusCode: 404 }, id: 'rule-1' }, - ]); + const parsed: BulkError[] = JSON.parse(payload); + const expected: BulkError[] = [ + { + error: { message: 'rule_id: "rule-1" not found', status_code: 404 }, + rule_id: 'rule-1', + }, + ]; + expect(parsed).toEqual(expected); }); test('returns 404 if actionClient is not available on the route', async () => { @@ -125,10 +130,14 @@ describe('update_rules_bulk', () => { payload: [typicalPayload()], }; const { payload } = await server.inject(request); - const parsed = JSON.parse(payload); - expect(parsed).toEqual([ - { error: { message: 'rule_id: "rule-1" not found', statusCode: 404 }, id: 'rule-1' }, - ]); + const parsed: BulkError[] = JSON.parse(payload); + const expected: BulkError[] = [ + { + error: { message: 'rule_id: "rule-1" not found', status_code: 404 }, + rule_id: 'rule-1', + }, + ]; + expect(parsed).toEqual(expected); }); test('returns 200 if type is query', async () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts index 44d47ad435682..c1b4c7de73f68 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts @@ -14,11 +14,15 @@ import { transformTags, getIdBulkError, transformOrBulkError, + transformRulesToNdjson, + transformAlertsToRules, + transformOrImportError, } from './utils'; import { getResult } from '../__mocks__/request_responses'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; import { OutputRuleAlertRest } from '../../types'; -import { BulkError } from '../utils'; +import { BulkError, ImportSuccessError } from '../utils'; +import { sampleRule } from '../../signals/__mocks__/es_results'; describe('utils', () => { describe('transformAlertToRule', () => { @@ -756,8 +760,8 @@ describe('utils', () => { test('outputs message about id not being found if only id is defined and ruleId is undefined', () => { const error = getIdBulkError({ id: '123', ruleId: undefined }); const expected: BulkError = { - id: '123', - error: { message: 'id: "123" not found', statusCode: 404 }, + rule_id: '123', + error: { message: 'id: "123" not found', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -765,8 +769,8 @@ describe('utils', () => { test('outputs message about id not being found if only id is defined and ruleId is null', () => { const error = getIdBulkError({ id: '123', ruleId: null }); const expected: BulkError = { - id: '123', - error: { message: 'id: "123" not found', statusCode: 404 }, + rule_id: '123', + error: { message: 'id: "123" not found', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -774,8 +778,8 @@ describe('utils', () => { test('outputs message about ruleId not being found if only ruleId is defined and id is undefined', () => { const error = getIdBulkError({ id: undefined, ruleId: 'rule-id-123' }); const expected: BulkError = { - id: 'rule-id-123', - error: { message: 'rule_id: "rule-id-123" not found', statusCode: 404 }, + rule_id: 'rule-id-123', + error: { message: 'rule_id: "rule-id-123" not found', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -783,8 +787,8 @@ describe('utils', () => { test('outputs message about ruleId not being found if only ruleId is defined and id is null', () => { const error = getIdBulkError({ id: null, ruleId: 'rule-id-123' }); const expected: BulkError = { - id: 'rule-id-123', - error: { message: 'rule_id: "rule-id-123" not found', statusCode: 404 }, + rule_id: 'rule-id-123', + error: { message: 'rule_id: "rule-id-123" not found', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -792,8 +796,8 @@ describe('utils', () => { test('outputs message about both being not defined when both are undefined', () => { const error = getIdBulkError({ id: undefined, ruleId: undefined }); const expected: BulkError = { - id: '(unknown id)', - error: { message: 'id or rule_id should have been defined', statusCode: 404 }, + rule_id: '(unknown id)', + error: { message: 'id or rule_id should have been defined', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -801,8 +805,8 @@ describe('utils', () => { test('outputs message about both being not defined when both are null', () => { const error = getIdBulkError({ id: null, ruleId: null }); const expected: BulkError = { - id: '(unknown id)', - error: { message: 'id or rule_id should have been defined', statusCode: 404 }, + rule_id: '(unknown id)', + error: { message: 'id or rule_id should have been defined', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -810,8 +814,8 @@ describe('utils', () => { test('outputs message about both being not defined when id is null and ruleId is undefined', () => { const error = getIdBulkError({ id: null, ruleId: undefined }); const expected: BulkError = { - id: '(unknown id)', - error: { message: 'id or rule_id should have been defined', statusCode: 404 }, + rule_id: '(unknown id)', + error: { message: 'id or rule_id should have been defined', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -819,8 +823,8 @@ describe('utils', () => { test('outputs message about both being not defined when id is undefined and ruleId is null', () => { const error = getIdBulkError({ id: undefined, ruleId: null }); const expected: BulkError = { - id: '(unknown id)', - error: { message: 'id or rule_id should have been defined', statusCode: 404 }, + rule_id: '(unknown id)', + error: { message: 'id or rule_id should have been defined', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -893,10 +897,279 @@ describe('utils', () => { test('returns 500 if the data is not of type siem alert', () => { const output = transformOrBulkError('rule-1', { data: [{ random: 1 }] }); - expect(output).toEqual({ - id: 'rule-1', - error: { message: 'Internal error transforming', statusCode: 500 }, + const expected: BulkError = { + rule_id: 'rule-1', + error: { message: 'Internal error transforming', status_code: 500 }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('transformRulesToNdjson', () => { + test('if rules are empty it returns an empty string', () => { + const ruleNdjson = transformRulesToNdjson([]); + expect(ruleNdjson).toEqual(''); + }); + + test('single rule will transform with new line ending character for ndjson', () => { + const rule = sampleRule(); + const ruleNdjson = transformRulesToNdjson([rule]); + expect(ruleNdjson.endsWith('\n')).toBe(true); + }); + + test('multiple rules will transform with two new line ending characters for ndjson', () => { + const result1 = sampleRule(); + const result2 = sampleRule(); + result2.id = 'some other id'; + result2.rule_id = 'some other id'; + result2.name = 'Some other rule'; + + const ruleNdjson = transformRulesToNdjson([result1, result2]); + // this is how we count characters in JavaScript :-) + const count = ruleNdjson.split('\n').length - 1; + expect(count).toBe(2); + }); + + test('you can parse two rules back out without errors', () => { + const result1 = sampleRule(); + const result2 = sampleRule(); + result2.id = 'some other id'; + result2.rule_id = 'some other id'; + result2.name = 'Some other rule'; + + const ruleNdjson = transformRulesToNdjson([result1, result2]); + const ruleStrings = ruleNdjson.split('\n'); + const reParsed1 = JSON.parse(ruleStrings[0]); + const reParsed2 = JSON.parse(ruleStrings[1]); + expect(reParsed1).toEqual(result1); + expect(reParsed2).toEqual(result2); + }); + }); + + describe('transformAlertsToRules', () => { + test('given an empty array returns an empty array', () => { + expect(transformAlertsToRules([])).toEqual([]); + }); + + test('given single alert will return the alert transformed', () => { + const result1 = getResult(); + const transformed = transformAlertsToRules([result1]); + expect(transformed).toEqual([ + { + created_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { someMeta: 'someField' }, + name: 'Detect Root/Admin Users', + output_index: '.siem-signals', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + risk_score: 50, + rule_id: 'rule-1', + saved_id: 'some-id', + severity: 'high', + tags: [], + threats: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + techniques: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + ], + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + to: 'now', + type: 'query', + updated_at: '2019-12-13T16:40:33.400Z', + updated_by: 'elastic', + version: 1, + }, + ]); + }); + + test('given two alerts will return the two alerts transformed', () => { + const result1 = getResult(); + const result2 = getResult(); + result2.id = 'some other id'; + result2.params.ruleId = 'some other id'; + + const transformed = transformAlertsToRules([result1, result2]); + expect(transformed).toEqual([ + { + created_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { someMeta: 'someField' }, + name: 'Detect Root/Admin Users', + output_index: '.siem-signals', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + risk_score: 50, + rule_id: 'rule-1', + saved_id: 'some-id', + severity: 'high', + tags: [], + threats: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + techniques: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + ], + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + to: 'now', + type: 'query', + updated_at: '2019-12-13T16:40:33.400Z', + updated_by: 'elastic', + version: 1, + }, + { + created_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: 'some other id', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { someMeta: 'someField' }, + name: 'Detect Root/Admin Users', + output_index: '.siem-signals', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + risk_score: 50, + rule_id: 'some other id', + saved_id: 'some-id', + severity: 'high', + tags: [], + threats: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + techniques: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + ], + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + to: 'now', + type: 'query', + updated_at: '2019-12-13T16:40:33.400Z', + updated_by: 'elastic', + version: 1, + }, + ]); + }); + }); + + describe('transformOrImportError', () => { + test('returns 1 given success if the alert is an alert type and the existing success count is 0', () => { + const output = transformOrImportError('rule-1', getResult(), { + success: true, + success_count: 0, + errors: [], + }); + const expected: ImportSuccessError = { + success: true, + errors: [], + success_count: 1, + }; + expect(output).toEqual(expected); + }); + + test('returns 2 given successes if the alert is an alert type and the existing success count is 1', () => { + const output = transformOrImportError('rule-1', getResult(), { + success: true, + success_count: 1, + errors: [], }); + const expected: ImportSuccessError = { + success: true, + errors: [], + success_count: 2, + }; + expect(output).toEqual(expected); + }); + + test('returns 1 error and success of false if the data is not of type siem alert', () => { + const output = transformOrImportError( + 'rule-1', + { data: [{ random: 1 }] }, + { + success: true, + success_count: 1, + errors: [], + } + ); + const expected: ImportSuccessError = { + success: false, + errors: [ + { + rule_id: 'rule-1', + error: { + message: 'Internal error transforming', + status_code: 500, + }, + }, + ], + success_count: 1, + }; + expect(output).toEqual(expected); }); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index 714035a423413..21972905a5063 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -9,7 +9,13 @@ import { pickBy } from 'lodash/fp'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; import { RuleAlertType, isAlertType, isAlertTypes } from '../../rules/types'; import { OutputRuleAlertRest } from '../../types'; -import { createBulkErrorObject, BulkError } from '../utils'; +import { + createBulkErrorObject, + BulkError, + createSuccessObject, + ImportSuccessError, + createImportErrorObject, +} from '../utils'; export const getIdError = ({ id, @@ -97,6 +103,21 @@ export const transformAlertToRule = (alert: RuleAlertType): Partial>): string => { + if (rules.length !== 0) { + const rulesString = rules.map(rule => JSON.stringify(rule)).join('\n'); + return `${rulesString}\n`; + } else { + return ''; + } +}; + +export const transformAlertsToRules = ( + alerts: RuleAlertType[] +): Array> => { + return alerts.map(alert => transformAlertToRule(alert)); +}; + export const transformFindAlertsOrError = (findResults: { data: unknown[] }): unknown | Boom => { if (isAlertTypes(findResults.data)) { findResults.data = findResults.data.map(alert => transformAlertToRule(alert)); @@ -128,3 +149,20 @@ export const transformOrBulkError = ( }); } }; + +export const transformOrImportError = ( + ruleId: string, + alert: unknown, + existingImportSuccessError: ImportSuccessError +): ImportSuccessError => { + if (isAlertType(alert)) { + return createSuccessObject(existingImportSuccessError); + } else { + return createImportErrorObject({ + ruleId, + statusCode: 500, + message: 'Internal error transforming', + existingImportSuccessError, + }); + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.test.ts new file mode 100644 index 0000000000000..7850e3a733f09 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { exportRulesSchema, exportRulesQuerySchema } from './export_rules_schema'; +import { ExportRulesRequest } from '../../rules/types'; + +describe('create rules schema', () => { + describe('exportRulesSchema', () => { + test('null value or absent values validate', () => { + expect(exportRulesSchema.validate(null).error).toBeFalsy(); + }); + + test('empty object does not validate', () => { + expect( + exportRulesSchema.validate>({}).error + ).toBeTruthy(); + }); + + test('empty object array does validate', () => { + expect( + exportRulesSchema.validate>({ objects: [] }).error + ).toBeTruthy(); + }); + + test('array with rule_id validates', () => { + expect( + exportRulesSchema.validate>({ + objects: [{ rule_id: 'test-1' }], + }).error + ).toBeFalsy(); + }); + + test('array with id does not validate as we do not allow that on purpose since we export rule_id', () => { + expect( + exportRulesSchema.validate>({ + objects: [{ id: 'test-1' }], + }).error + ).toBeTruthy(); + }); + }); + + describe('exportRulesQuerySchema', () => { + test('default value for file_name is export.ndjson', () => { + expect( + exportRulesQuerySchema.validate>({}).value.file_name + ).toEqual('export.ndjson'); + }); + + test('default value for exclude_export_details is false', () => { + expect( + exportRulesQuerySchema.validate>({}).value + .exclude_export_details + ).toEqual(false); + }); + + test('file_name validates', () => { + expect( + exportRulesQuerySchema.validate>({ + file_name: 'test.ndjson', + }).error + ).toBeFalsy(); + }); + + test('file_name does not validate with a number', () => { + expect( + exportRulesQuerySchema.validate< + Partial & { file_name: number }> + >({ + file_name: 5, + }).error + ).toBeTruthy(); + }); + + test('exclude_export_details validates with a boolean true', () => { + expect( + exportRulesQuerySchema.validate>({ + exclude_export_details: true, + }).error + ).toBeFalsy(); + }); + + test('exclude_export_details does not validate with a weird string', () => { + expect( + exportRulesQuerySchema.validate< + Partial< + Omit & { + exclude_export_details: string; + } + > + >({ + exclude_export_details: 'blah', + }).error + ).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.ts new file mode 100644 index 0000000000000..a14d81604d9f8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.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 Joi from 'joi'; + +/* eslint-disable @typescript-eslint/camelcase */ +import { objects, exclude_export_details, file_name } from './schemas'; +/* eslint-disable @typescript-eslint/camelcase */ + +export const exportRulesSchema = Joi.object({ + objects, +}) + .min(1) + .allow(null); + +export const exportRulesQuerySchema = Joi.object({ + file_name: file_name.default('export.ndjson'), + exclude_export_details: exclude_export_details.default(false), +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts new file mode 100644 index 0000000000000..09bc7a70711ec --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts @@ -0,0 +1,1327 @@ +/* + * 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 { + importRulesSchema, + importRulesQuerySchema, + importRulesPayloadSchema, +} from './import_rules_schema'; +import { ThreatParams, RuleAlertParamsRest, ImportRuleAlertRest } from '../../types'; +import { ImportRulesRequest } from '../../rules/types'; + +describe('import rules schema', () => { + describe('importRulesSchema', () => { + test('empty objects do not validate', () => { + expect(importRulesSchema.validate>({}).error).toBeTruthy(); + }); + + test('made up values do not validate', () => { + expect( + importRulesSchema.validate>({ + madeUp: 'hi', + }).error + ).toBeTruthy(); + }); + + test('[rule_id] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name, severity] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'severity', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name, severity, type] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'severity', + type: 'query', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name, severity, type, interval] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name, severity, type, interval, index] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'severity', + type: 'query', + interval: '5m', + index: ['index-1'], + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name, severity, type, query, index, interval] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'severity', + type: 'query', + query: 'some query', + index: ['index-1'], + interval: '5m', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, query, language] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some query', + language: 'kuery', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, query, language, risk_score] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, query, language, risk_score, output_index] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + risk_score: 50, + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, output_index] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + }).error + ).toBeFalsy(); + }); + test('You can send in an empty array to threats', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threats: [], + }).error + ).toBeFalsy(); + }); + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, output_index, threats] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + threats: [ + { + framework: 'someFramework', + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + techniques: [ + { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + ], + }, + ], + }).error + ).toBeFalsy(); + }); + + test('allows references to be sent as valid', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('defaults references to an array', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some-query', + language: 'kuery', + }).value.references + ).toEqual([]); + }); + + test('references cannot be numbers', () => { + expect( + importRulesSchema.validate< + Partial> & { references: number[] } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some-query', + language: 'kuery', + references: [5], + }).error + ).toBeTruthy(); + }); + + test('indexes cannot be numbers', () => { + expect( + importRulesSchema.validate< + Partial> & { index: number[] } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: [5], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some-query', + language: 'kuery', + }).error + ).toBeTruthy(); + }); + + test('defaults interval to 5 min', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + type: 'query', + }).value.interval + ).toEqual('5m'); + }); + + test('defaults max signals to 100', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + }).value.max_signals + ).toEqual(100); + }); + + test('saved_id is required when type is saved_query and will not validate without out', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + }).error + ).toBeTruthy(); + }); + + test('saved_id is required when type is saved_query and validates with it', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + output_index: '.siem-signals', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + }).error + ).toBeFalsy(); + }); + + test('saved_query type can have filters with it', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + filters: [], + }).error + ).toBeFalsy(); + }); + + test('filters cannot be a string', () => { + expect( + importRulesSchema.validate< + Partial & { filters: string }> + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + filters: 'some string', + }).error + ).toBeTruthy(); + }); + + test('language validates with kuery', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('language validates with lucene', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + output_index: '.siem-signals', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'lucene', + }).error + ).toBeFalsy(); + }); + + test('language does not validate with something made up', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'something-made-up', + }).error + ).toBeTruthy(); + }); + + test('max_signals cannot be negative', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: -1, + }).error + ).toBeTruthy(); + }); + + test('max_signals cannot be zero', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 0, + }).error + ).toBeTruthy(); + }); + + test('max_signals can be 1', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You can optionally send in an array of tags', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + tags: ['tag_1', 'tag_2'], + }).error + ).toBeFalsy(); + }); + + test('You cannot send in an array of tags that are numbers', () => { + expect( + importRulesSchema.validate> & { tags: number[] }>( + { + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + tags: [0, 1, 2], + } + ).error + ).toBeTruthy(); + }); + + test('You cannot send in an array of threats that are missing "framework"', () => { + expect( + importRulesSchema.validate< + Partial> & { + threats: Array>>; + } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threats: [ + { + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + techniques: [ + { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + ], + }, + ], + }).error + ).toBeTruthy(); + }); + test('You cannot send in an array of threats that are missing "tactic"', () => { + expect( + importRulesSchema.validate< + Partial> & { + threats: Array>>; + } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threats: [ + { + framework: 'fake', + techniques: [ + { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + ], + }, + ], + }).error + ).toBeTruthy(); + }); + test('You cannot send in an array of threats that are missing "techniques"', () => { + expect( + importRulesSchema.validate< + Partial> & { + threats: Array>>; + } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threats: [ + { + framework: 'fake', + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + }, + ], + }).error + ).toBeTruthy(); + }); + + test('You can optionally send in an array of false positives', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + false_positives: ['false_1', 'false_2'], + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You cannot send in an array of false positives that are numbers', () => { + expect( + importRulesSchema.validate< + Partial> & { false_positives: number[] } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + false_positives: [5, 4], + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeTruthy(); + }); + + test('You can optionally set the immutable to be true', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You cannot set the immutable to be a number', () => { + expect( + importRulesSchema.validate< + Partial> & { immutable: number } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: 5, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeTruthy(); + }); + + test('You cannot set the risk_score to 101', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 101, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeTruthy(); + }); + + test('You cannot set the risk_score to -1', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: -1, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeTruthy(); + }); + + test('You can set the risk_score to 0', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 0, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You can set the risk_score to 100', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 100, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You can set meta to any object you want', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + meta: { + somethingMadeUp: { somethingElse: true }, + }, + }).error + ).toBeFalsy(); + }); + + test('You cannot create meta as a string', () => { + expect( + importRulesSchema.validate & { meta: string }>>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + meta: 'should not work', + }).error + ).toBeTruthy(); + }); + + test('You can omit the query string when filters are present', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('validates with timeline_id and timeline_title', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'timeline-id', + timeline_title: 'timeline-title', + }).error + ).toBeFalsy(); + }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + }).error + ).toBeTruthy(); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + timeline_title: null, + }).error + ).toBeTruthy(); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + timeline_title: '', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: '', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('rule_id is required and you cannot get by with just id', () => { + expect( + importRulesSchema.validate>({ + id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + }).error + ).toBeTruthy(); + }); + + test('it validates with created_at, updated_at, created_by, updated_by values', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + created_at: '2020-01-09T06:15:24.749Z', + updated_at: '2020-01-09T06:15:24.749Z', + created_by: 'Braden Hassanabad', + updated_by: 'Evan Hassanabad', + }).error + ).toBeFalsy(); + }); + + test('it does not validate with epoch strings for created_at', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + created_at: '1578550728650', + updated_at: '2020-01-09T06:15:24.749Z', + created_by: 'Braden Hassanabad', + updated_by: 'Evan Hassanabad', + }).error + ).toBeTruthy(); + }); + + test('it does not validate with epoch strings for updated_at', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + created_at: '2020-01-09T06:15:24.749Z', + updated_at: '1578550728650', + created_by: 'Braden Hassanabad', + updated_by: 'Evan Hassanabad', + }).error + ).toBeTruthy(); + }); + }); + + describe('importRulesQuerySchema', () => { + test('overwrite gets a default value of false', () => { + expect( + importRulesQuerySchema.validate>({}).value.overwrite + ).toEqual(false); + }); + + test('overwrite validates with a boolean true', () => { + expect( + importRulesQuerySchema.validate>({ + overwrite: true, + }).error + ).toBeFalsy(); + }); + + test('overwrite does not validate with a weird string', () => { + expect( + importRulesQuerySchema.validate< + Partial< + Omit & { + overwrite: string; + } + > + >({ + overwrite: 'blah', + }).error + ).toBeTruthy(); + }); + }); + + describe('importRulesPayloadSchema', () => { + test('does not validate with an empty object', () => { + expect(importRulesPayloadSchema.validate({}).error).toBeTruthy(); + }); + + test('does validate with a file object', () => { + expect(importRulesPayloadSchema.validate({ file: {} }).error).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts new file mode 100644 index 0000000000000..df825c442fff6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts @@ -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 Joi from 'joi'; + +/* eslint-disable @typescript-eslint/camelcase */ +import { + id, + created_at, + updated_at, + created_by, + updated_by, + enabled, + description, + false_positives, + filters, + from, + immutable, + index, + rule_id, + interval, + query, + language, + output_index, + saved_id, + timeline_id, + timeline_title, + meta, + risk_score, + max_signals, + name, + severity, + tags, + to, + type, + threats, + references, + version, +} from './schemas'; +/* eslint-enable @typescript-eslint/camelcase */ + +import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants'; + +/** + * Differences from this and the createRulesSchema are + * - rule_id is required + * - id is optional (but ignored in the import code - rule_id is exclusively used for imports) + * - created_at is optional (but ignored in the import code) + * - updated_at is optional (but ignored in the import code) + * - created_by is optional (but ignored in the import code) + * - updated_by is optional (but ignored in the import code) + */ +export const importRulesSchema = Joi.object({ + id, + description: description.required(), + enabled: enabled.default(true), + false_positives: false_positives.default([]), + filters, + from: from.required(), + rule_id: rule_id.required(), + immutable: immutable.default(false), + index, + interval: interval.default('5m'), + query: query.allow('').default(''), + language: language.default('kuery'), + output_index, + saved_id: saved_id.when('type', { + is: 'saved_query', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), + timeline_id, + timeline_title, + meta, + risk_score: risk_score.required(), + max_signals: max_signals.default(DEFAULT_MAX_SIGNALS), + name: name.required(), + severity: severity.required(), + tags: tags.default([]), + to: to.required(), + type: type.required(), + threats: threats.default([]), + references: references.default([]), + version: version.default(1), + created_at, + updated_at, + created_by, + updated_by, +}); + +export const importRulesQuerySchema = Joi.object({ + overwrite: Joi.boolean().default(false), +}); + +export const importRulesPayloadSchema = Joi.object({ + file: Joi.object().required(), +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts index c8331bb7820dd..ecca661d2b856 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts @@ -9,7 +9,9 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ export const description = Joi.string(); export const enabled = Joi.boolean(); +export const exclude_export_details = Joi.boolean(); export const false_positives = Joi.array().items(Joi.string()); +export const file_name = Joi.string(); export const filters = Joi.array(); export const from = Joi.string(); export const immutable = Joi.boolean(); @@ -21,6 +23,11 @@ export const index = Joi.array() export const interval = Joi.string(); export const query = Joi.string(); export const language = Joi.string().valid('kuery', 'lucene'); +export const objects = Joi.array().items( + Joi.object({ + rule_id, + }).required() +); export const output_index = Joi.string(); export const saved_id = Joi.string(); export const timeline_id = Joi.string(); @@ -75,7 +82,6 @@ export const threat_technique = Joi.object({ reference: threat_technique_reference.required(), }); export const threat_techniques = Joi.array().items(threat_technique.required()); - export const threats = Joi.array().items( Joi.object({ framework: threat_framework.required(), @@ -83,5 +89,12 @@ export const threats = Joi.array().items( techniques: threat_techniques.required(), }) ); - +export const created_at = Joi.string() + .isoDate() + .strict(); +export const updated_at = Joi.string() + .isoDate() + .strict(); +export const created_by = Joi.string(); +export const updated_by = Joi.string(); export const version = Joi.number().min(1); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts index fa95c77f646d6..ffd0c791c5bb6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts @@ -6,7 +6,15 @@ import Boom from 'boom'; -import { transformError, transformBulkError, BulkError } from './utils'; +import { + transformError, + transformBulkError, + BulkError, + createSuccessObject, + ImportSuccessError, + createImportErrorObject, + transformImportError, +} from './utils'; describe('utils', () => { describe('transformError', () => { @@ -63,8 +71,8 @@ describe('utils', () => { const boom = new Boom('some boom message', { statusCode: 400 }); const transformed = transformBulkError('rule-1', boom); const expected: BulkError = { - id: 'rule-1', - error: { message: 'some boom message', statusCode: 400 }, + rule_id: 'rule-1', + error: { message: 'some boom message', status_code: 400 }, }; expect(transformed).toEqual(expected); }); @@ -77,8 +85,8 @@ describe('utils', () => { }; const transformed = transformBulkError('rule-1', error); const expected: BulkError = { - id: 'rule-1', - error: { message: 'some message', statusCode: 403 }, + rule_id: 'rule-1', + error: { message: 'some message', status_code: 403 }, }; expect(transformed).toEqual(expected); }); @@ -90,8 +98,8 @@ describe('utils', () => { }; const transformed = transformBulkError('rule-1', error); const expected: BulkError = { - id: 'rule-1', - error: { message: 'some message', statusCode: 500 }, + rule_id: 'rule-1', + error: { message: 'some message', status_code: 500 }, }; expect(transformed).toEqual(expected); }); @@ -100,8 +108,168 @@ describe('utils', () => { const error: TypeError = new TypeError('I have a type error'); const transformed = transformBulkError('rule-1', error); const expected: BulkError = { - id: 'rule-1', - error: { message: 'I have a type error', statusCode: 400 }, + rule_id: 'rule-1', + error: { message: 'I have a type error', status_code: 400 }, + }; + expect(transformed).toEqual(expected); + }); + }); + + describe('createSuccessObject', () => { + test('it should increment the existing success object by 1', () => { + const success = createSuccessObject({ + success_count: 0, + success: true, + errors: [], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: true, + errors: [], + }; + expect(success).toEqual(expected); + }); + + test('it should increment the existing success object by 1 and not touch the boolean or errors', () => { + const success = createSuccessObject({ + success_count: 0, + success: false, + errors: [ + { rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } }, + ], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } }, + ], + }; + expect(success).toEqual(expected); + }); + }); + + describe('createImportErrorObject', () => { + test('it creates an error message and does not increment the success count', () => { + const error = createImportErrorObject({ + ruleId: 'some-rule-id', + statusCode: 400, + message: 'some-message', + existingImportSuccessError: { + success_count: 1, + success: true, + errors: [], + }, + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }], + }; + expect(error).toEqual(expected); + }); + + test('appends a second error message and does not increment the success count', () => { + const error = createImportErrorObject({ + ruleId: 'some-rule-id', + statusCode: 400, + message: 'some-message', + existingImportSuccessError: { + success_count: 1, + success: false, + errors: [ + { rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } }, + ], + }, + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } }, + { rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }, + ], + }; + expect(error).toEqual(expected); + }); + }); + + describe('transformImportError', () => { + test('returns transformed object if it is a boom object', () => { + const boom = new Boom('some boom message', { statusCode: 400 }); + const transformed = transformImportError('rule-1', boom, { + success_count: 1, + success: false, + errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }, + { rule_id: 'rule-1', error: { status_code: 400, message: 'some boom message' } }, + ], + }; + expect(transformed).toEqual(expected); + }); + + test('returns a normal error if it is some non boom object that has a statusCode', () => { + const error: Error & { statusCode?: number } = { + statusCode: 403, + name: 'some name', + message: 'some message', + }; + const transformed = transformImportError('rule-1', error, { + success_count: 1, + success: false, + errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }, + { rule_id: 'rule-1', error: { status_code: 403, message: 'some message' } }, + ], + }; + expect(transformed).toEqual(expected); + }); + + test('returns a 500 if the status code is not set', () => { + const error: Error & { statusCode?: number } = { + name: 'some name', + message: 'some message', + }; + const transformed = transformImportError('rule-1', error, { + success_count: 1, + success: false, + errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }, + { rule_id: 'rule-1', error: { status_code: 500, message: 'some message' } }, + ], + }; + expect(transformed).toEqual(expected); + }); + + test('it detects a TypeError and returns a Boom status of 400', () => { + const error: TypeError = new TypeError('I have a type error'); + const transformed = transformImportError('rule-1', error, { + success_count: 1, + success: false, + errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }, + { rule_id: 'rule-1', error: { status_code: 400, message: 'I have a type error' } }, + ], }; expect(transformed).toEqual(expected); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts index d9a8efd673883..19cd972b60e1a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts @@ -27,12 +27,13 @@ export const transformError = (err: Error & { statusCode?: number }) => { }; export interface BulkError { - id: string; + rule_id: string; error: { - statusCode: number; + status_code: number; message: string; }; } + export const createBulkErrorObject = ({ ruleId, statusCode, @@ -43,14 +44,84 @@ export const createBulkErrorObject = ({ message: string; }): BulkError => { return { - id: ruleId, + rule_id: ruleId, error: { - statusCode, + status_code: statusCode, message, }, }; }; +export interface ImportSuccessError { + success: boolean; + success_count: number; + errors: BulkError[]; +} + +export const createSuccessObject = ( + existingImportSuccessError: ImportSuccessError +): ImportSuccessError => { + return { + success_count: existingImportSuccessError.success_count + 1, + success: existingImportSuccessError.success, + errors: existingImportSuccessError.errors, + }; +}; + +export const createImportErrorObject = ({ + ruleId, + statusCode, + message, + existingImportSuccessError, +}: { + ruleId: string; + statusCode: number; + message: string; + existingImportSuccessError: ImportSuccessError; +}): ImportSuccessError => { + return { + success: false, + errors: [ + ...existingImportSuccessError.errors, + createBulkErrorObject({ + ruleId, + statusCode, + message, + }), + ], + success_count: existingImportSuccessError.success_count, + }; +}; + +export const transformImportError = ( + ruleId: string, + err: Error & { statusCode?: number }, + existingImportSuccessError: ImportSuccessError +): ImportSuccessError => { + if (Boom.isBoom(err)) { + return createImportErrorObject({ + ruleId, + statusCode: err.output.statusCode, + message: err.message, + existingImportSuccessError, + }); + } else if (err instanceof TypeError) { + return createImportErrorObject({ + ruleId, + statusCode: 400, + message: err.message, + existingImportSuccessError, + }); + } else { + return createImportErrorObject({ + ruleId, + statusCode: err.statusCode ?? 500, + message: err.message, + existingImportSuccessError, + }); + } +}; + export const transformBulkError = ( ruleId: string, err: Error & { statusCode?: number } @@ -76,13 +147,19 @@ export const transformBulkError = ( } }; -export const getIndex = (request: RequestFacade, server: ServerFacade): string => { +export const getIndex = ( + request: RequestFacade | Omit, + server: ServerFacade +): string => { const spaceId = server.plugins.spaces.getSpaceId(request); const signalsIndex = server.config().get(`xpack.${APP_ID}.${SIGNALS_INDEX_KEY}`); return `${signalsIndex}-${spaceId}`; }; -export const callWithRequestFactory = (request: RequestFacade, server: ServerFacade) => { +export const callWithRequestFactory = ( + request: RequestFacade | Omit, + server: ServerFacade +) => { const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); return (endpoint: string, params: T, options?: U) => { return callWithRequest(request, endpoint, params, options); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts new file mode 100644 index 0000000000000..fce3c90ef18e7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts @@ -0,0 +1,375 @@ +/* + * 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 { Readable } from 'stream'; +import { createRulesStreamFromNdJson } from './create_rules_stream_from_ndjson'; +import { createPromiseFromStreams, createConcatStream } from 'src/legacy/utils/streams'; +import { ImportRuleAlertRest } from '../types'; + +const readStreamToCompletion = (stream: Readable) => { + return createPromiseFromStreams([stream, createConcatStream([])]); +}; + +export const getOutputSample = (): Partial => ({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', +}); + +export const getSampleAsNdjson = (sample: Partial): string => { + return `${JSON.stringify(sample)}\n`; +}; + +describe('create_rules_stream_from_ndjson', () => { + describe('createRulesStreamFromNdJson', () => { + test('transforms an ndjson stream into a stream of rule objects', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push(getSampleAsNdjson(sample2)); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + expect(result).toEqual([ + { + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + { + rule_id: 'rule-2', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + ]); + }); + + test('skips empty lines', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push('\n'); + this.push(getSampleAsNdjson(sample2)); + this.push(''); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + expect(result).toEqual([ + { + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + { + rule_id: 'rule-2', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + ]); + }); + + test('filters the export details entry from the stream', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push(getSampleAsNdjson(sample2)); + this.push('{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n'); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + expect(result).toEqual([ + { + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + { + rule_id: 'rule-2', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + ]); + }); + + test('handles non parsable JSON strings and inserts the error as part of the return array', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push('{,,,,\n'); + this.push(getSampleAsNdjson(sample2)); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + const resultOrError = result as Error[]; + expect(resultOrError[0]).toEqual({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }); + expect(resultOrError[1].message).toEqual('Unexpected token , in JSON at position 1'); + expect(resultOrError[2]).toEqual({ + rule_id: 'rule-2', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }); + }); + + test('handles non-validated data', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push(`{}\n`); + this.push(getSampleAsNdjson(sample2)); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + const resultOrError = result as TypeError[]; + expect(resultOrError[0]).toEqual({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }); + expect(resultOrError[1].message).toEqual( + 'child "description" fails because ["description" is required]' + ); + expect(resultOrError[2]).toEqual({ + rule_id: 'rule-2', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }); + }); + + test('non validated data is an instanceof TypeError', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push(`{}\n`); + this.push(getSampleAsNdjson(sample2)); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + const resultOrError = result as TypeError[]; + expect(resultOrError[1] instanceof TypeError).toEqual(true); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts new file mode 100644 index 0000000000000..6d58171a3245d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts @@ -0,0 +1,88 @@ +/* + * 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 { Readable, Transform } from 'stream'; +import { has, isString } from 'lodash/fp'; +import { ImportRuleAlertRest } from '../types'; +import { + createSplitStream, + createMapStream, + createFilterStream, + createConcatStream, +} from '../../../../../../../../src/legacy/utils/streams'; +import { importRulesSchema } from '../routes/schemas/import_rules_schema'; + +export interface RulesObjectsExportResultDetails { + /** number of successfully exported objects */ + exportedCount: number; +} + +export const parseNdjsonStrings = (): Transform => { + return createMapStream((ndJsonStr: string) => { + if (isString(ndJsonStr) && ndJsonStr.trim() !== '') { + try { + return JSON.parse(ndJsonStr); + } catch (err) { + return err; + } + } + }); +}; + +export const filterExportedCounts = (): Transform => { + return createFilterStream( + obj => obj != null && !has('exported_count', obj) + ); +}; + +export const validateRules = (): Transform => { + return createMapStream((obj: ImportRuleAlertRest) => { + if (!(obj instanceof Error)) { + const validated = importRulesSchema.validate(obj); + if (validated.error != null) { + return new TypeError(validated.error.message); + } else { + return validated.value; + } + } else { + return obj; + } + }); +}; + +// Adaptation from: saved_objects/import/create_limit_stream.ts +export const createLimitStream = (limit: number): Transform => { + let counter = 0; + return new Transform({ + objectMode: true, + async transform(obj, _, done) { + if (counter >= limit) { + return done(new Error(`Can't import more than ${limit} rules`)); + } + counter++; + done(undefined, obj); + }, + }); +}; + +// TODO: Capture both the line number and the rule_id if you have that information for the error message +// eventually and then pass it down so we can give error messages on the line number + +/** + * Inspiration and the pattern of code followed is from: + * saved_objects/lib/create_saved_objects_stream_from_ndjson.ts + */ +export const createRulesStreamFromNdJson = ( + ndJsonStream: Readable, + ruleLimit: number +): Transform => { + return ndJsonStream + .pipe(createSplitStream('\n')) + .pipe(parseNdjsonStrings()) + .pipe(filterExportedCounts()) + .pipe(validateRules()) + .pipe(createLimitStream(ruleLimit)) + .pipe(createConcatStream([])); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts index 5f69082e3fc71..e193e123f4281 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { FindResult } from '../../../../../alerting/server/alerts_client'; import { SIGNALS_ID } from '../../../../common/constants'; -import { FindRuleParams, RuleAlertType } from './types'; +import { FindRuleParams } from './types'; export const getFilter = (filter: string | null | undefined) => { if (filter == null) { @@ -23,7 +24,7 @@ export const findRules = async ({ filter, sortField, sortOrder, -}: FindRuleParams) => { +}: FindRuleParams): Promise => { return alertsClient.find({ options: { fields, @@ -33,10 +34,5 @@ export const findRules = async ({ sortOrder, sortField, }, - }) as Promise<{ - page: number; - perPage: number; - total: number; - data: RuleAlertType[]; - }>; + }); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts index bb28a5575f51e..dc308263baab6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts @@ -11,7 +11,13 @@ import { getFindResultWithSingleHit, getFindResultWithMultiHits, } from '../routes/__mocks__/request_responses'; -import { getExistingPrepackagedRules } from './get_existing_prepackaged_rules'; +import { + getExistingPrepackagedRules, + getNonPackagedRules, + getRules, + getRulesCount, + getNonPackagedRulesCount, +} from './get_existing_prepackaged_rules'; describe('get_existing_prepackaged_rules', () => { afterEach(() => { @@ -33,9 +39,11 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); const result1 = getResult(); + result1.params.immutable = true; result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; const result2 = getResult(); + result2.params.immutable = true; result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; alertsClient.find.mockResolvedValueOnce( @@ -56,12 +64,15 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); const result1 = getResult(); + result1.params.immutable = true; result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; const result2 = getResult(); + result2.params.immutable = true; result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; const result3 = getResult(); + result3.params.immutable = true; result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; alertsClient.find.mockResolvedValueOnce( @@ -87,12 +98,15 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); const result1 = getResult(); + result1.params.immutable = true; result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; const result2 = getResult(); + result2.params.immutable = true; result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; const result3 = getResult(); + result3.params.immutable = true; result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; alertsClient.find.mockResolvedValueOnce( @@ -111,4 +125,221 @@ describe('get_existing_prepackaged_rules', () => { expect(rules).toEqual([result1, result2, result3]); }); }); + + describe('getNonPackagedRules', () => { + test('should return a single item in a single page', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getNonPackagedRules({ + alertsClient: unsafeCast, + }); + expect(rules).toEqual([getResult()]); + }); + + test('should return 2 items over two pages, one per page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 2 }) + ); + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 2 }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getNonPackagedRules({ + alertsClient: unsafeCast, + }); + expect(rules).toEqual([result1, result2]); + }); + + test('should return 3 items with over 3 pages one per page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result3 = getResult(); + result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 3 }) + ); + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 3 }) + ); + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result3], perPage: 1, page: 2, total: 3 }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getNonPackagedRules({ + alertsClient: unsafeCast, + }); + expect(rules).toEqual([result1, result2, result3]); + }); + + test('should return 3 items over 1 pages with all on one page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result3 = getResult(); + result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ + data: [result1, result2, result3], + perPage: 3, + page: 1, + total: 3, + }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getNonPackagedRules({ + alertsClient: unsafeCast, + }); + expect(rules).toEqual([result1, result2, result3]); + }); + }); + + describe('getRules', () => { + test('should return a single item in a single page', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getRules({ + alertsClient: unsafeCast, + filter: '', + }); + expect(rules).toEqual([getResult()]); + }); + + test('should return 2 items over two pages, one per page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 2 }) + ); + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 2 }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getRules({ + alertsClient: unsafeCast, + filter: '', + }); + expect(rules).toEqual([result1, result2]); + }); + + test('should return 3 items with over 3 pages one per page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result3 = getResult(); + result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 3 }) + ); + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 3 }) + ); + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result3], perPage: 1, page: 2, total: 3 }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getRules({ + alertsClient: unsafeCast, + filter: '', + }); + expect(rules).toEqual([result1, result2, result3]); + }); + + test('should return 3 items over 1 pages with all on one page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result3 = getResult(); + result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ + data: [result1, result2, result3], + perPage: 3, + page: 1, + total: 3, + }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getRules({ + alertsClient: unsafeCast, + filter: '', + }); + expect(rules).toEqual([result1, result2, result3]); + }); + }); + + describe('getRulesCount', () => { + test('it returns a count', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getRulesCount({ + alertsClient: unsafeCast, + filter: '', + }); + expect(rules).toEqual(1); + }); + }); + + describe('getNonPackagedRulesCount', () => { + test('it returns a count', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getNonPackagedRulesCount({ + alertsClient: unsafeCast, + }); + expect(rules).toEqual(1); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.ts index fa2e2124d0539..b7ab6a97634a8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.ts @@ -9,18 +9,46 @@ import { AlertsClient } from '../../../../../alerting'; import { RuleAlertType, isAlertTypes } from './types'; import { findRules } from './find_rules'; -export const DEFAULT_PER_PAGE: number = 100; +export const DEFAULT_PER_PAGE = 100; +export const FILTER_NON_PREPACKED_RULES = `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:false"`; +export const FILTER_PREPACKED_RULES = `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`; -export const getExistingPrepackagedRules = async ({ +export const getNonPackagedRulesCount = async ({ + alertsClient, +}: { + alertsClient: AlertsClient; +}): Promise => { + return getRulesCount({ alertsClient, filter: FILTER_NON_PREPACKED_RULES }); +}; + +export const getRulesCount = async ({ + alertsClient, + filter, +}: { + alertsClient: AlertsClient; + filter: string; +}): Promise => { + const firstRule = await findRules({ + alertsClient, + filter, + perPage: 1, + page: 1, + }); + return firstRule.total; +}; + +export const getRules = async ({ alertsClient, perPage = DEFAULT_PER_PAGE, + filter, }: { alertsClient: AlertsClient; perPage?: number; + filter: string; }): Promise => { const firstPrepackedRules = await findRules({ alertsClient, - filter: `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`, + filter, perPage, page: 1, }); @@ -40,7 +68,7 @@ export const getExistingPrepackagedRules = async ({ // page index starts at 2 as we already got the first page and we have more pages to go return findRules({ alertsClient, - filter: `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`, + filter, perPage, page: page + 2, }); @@ -58,3 +86,31 @@ export const getExistingPrepackagedRules = async ({ } } }; + +export const getNonPackagedRules = async ({ + alertsClient, + perPage = DEFAULT_PER_PAGE, +}: { + alertsClient: AlertsClient; + perPage?: number; +}): Promise => { + return getRules({ + alertsClient, + perPage, + filter: FILTER_NON_PREPACKED_RULES, + }); +}; + +export const getExistingPrepackagedRules = async ({ + alertsClient, + perPage = DEFAULT_PER_PAGE, +}: { + alertsClient: AlertsClient; + perPage?: number; +}): Promise => { + return getRules({ + alertsClient, + perPage, + filter: FILTER_PREPACKED_RULES, + }); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts new file mode 100644 index 0000000000000..eb9756af8fde1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../alerting/server/alerts_client.mock'; +import { + getResult, + getFindResultWithSingleHit, + FindHit, +} from '../routes/__mocks__/request_responses'; +import { AlertsClient } from '../../../../../alerting'; +import { getExportAll } from './get_export_all'; + +describe('getExportAll', () => { + test('it exports everything from the alerts client', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.get.mockResolvedValue(getResult()); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const exports = await getExportAll(unsafeCast); + expect(exports).toEqual({ + rulesNdjson: + '{"created_at":"2019-12-13T16:40:33.400Z","updated_at":"2019-12-13T16:40:33.400Z","created_by":"elastic","description":"Detecting root and admin users","enabled":true,"false_positives":[],"filters":[{"query":{"match_phrase":{"host.name":"some-host"}}}],"from":"now-6m","id":"04128c15-0d1b-4716-a4c5-46997ac7f3bd","immutable":false,"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"rule-1","language":"kuery","output_index":".siem-signals","max_signals":100,"risk_score":50,"name":"Detect Root/Admin Users","query":"user.name: root or user.name: admin","references":["http://www.example.com","https://ww.example.com"],"saved_id":"some-id","timeline_id":"some-timeline-id","timeline_title":"some-timeline-title","meta":{"someMeta":"someField"},"severity":"high","updated_by":"elastic","tags":[],"to":"now","type":"query","threats":[{"framework":"MITRE ATT&CK","tactic":{"id":"TA0040","name":"impact","reference":"https://attack.mitre.org/tactics/TA0040/"},"techniques":[{"id":"T1499","name":"endpoint denial of service","reference":"https://attack.mitre.org/techniques/T1499/"}]}],"version":1}\n', + exportDetails: '{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n', + }); + }); + + test('it will export empty rules', async () => { + const alertsClient = alertsClientMock.create(); + const findResult: FindHit = { + page: 1, + perPage: 1, + total: 0, + data: [], + }; + + alertsClient.find.mockResolvedValue(findResult); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const exports = await getExportAll(unsafeCast); + expect(exports).toEqual({ + rulesNdjson: '', + exportDetails: '{"exported_count":0,"missing_rules":[],"missing_rules_count":0}\n', + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts new file mode 100644 index 0000000000000..dca6eba4e6556 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertsClient } from '../../../../../alerting'; +import { getNonPackagedRules } from './get_existing_prepackaged_rules'; +import { getExportDetailsNdjson } from './get_export_details_ndjson'; +import { transformAlertsToRules, transformRulesToNdjson } from '../routes/rules/utils'; + +export const getExportAll = async ( + alertsClient: AlertsClient +): Promise<{ + rulesNdjson: string; + exportDetails: string; +}> => { + const ruleAlertTypes = await getNonPackagedRules({ alertsClient }); + const rules = transformAlertsToRules(ruleAlertTypes); + const rulesNdjson = transformRulesToNdjson(rules); + const exportDetails = getExportDetailsNdjson(rules); + return { rulesNdjson, exportDetails }; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts new file mode 100644 index 0000000000000..a861d80a66fd5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -0,0 +1,173 @@ +/* + * 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 { alertsClientMock } from '../../../../../alerting/server/alerts_client.mock'; +import { getExportByObjectIds, getRulesFromObjects, RulesErrors } from './get_export_by_object_ids'; +import { + getResult, + getFindResultWithSingleHit, + FindHit, +} from '../routes/__mocks__/request_responses'; +import { AlertsClient } from '../../../../../alerting'; + +describe('get_export_by_object_ids', () => { + describe('getExportByObjectIds', () => { + test('it exports object ids into an expected string with new line characters', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.get.mockResolvedValue(getResult()); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const objects = [{ rule_id: 'rule-1' }]; + const exports = await getExportByObjectIds(unsafeCast, objects); + expect(exports).toEqual({ + rulesNdjson: + '{"created_at":"2019-12-13T16:40:33.400Z","updated_at":"2019-12-13T16:40:33.400Z","created_by":"elastic","description":"Detecting root and admin users","enabled":true,"false_positives":[],"filters":[{"query":{"match_phrase":{"host.name":"some-host"}}}],"from":"now-6m","id":"04128c15-0d1b-4716-a4c5-46997ac7f3bd","immutable":false,"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"rule-1","language":"kuery","output_index":".siem-signals","max_signals":100,"risk_score":50,"name":"Detect Root/Admin Users","query":"user.name: root or user.name: admin","references":["http://www.example.com","https://ww.example.com"],"saved_id":"some-id","timeline_id":"some-timeline-id","timeline_title":"some-timeline-title","meta":{"someMeta":"someField"},"severity":"high","updated_by":"elastic","tags":[],"to":"now","type":"query","threats":[{"framework":"MITRE ATT&CK","tactic":{"id":"TA0040","name":"impact","reference":"https://attack.mitre.org/tactics/TA0040/"},"techniques":[{"id":"T1499","name":"endpoint denial of service","reference":"https://attack.mitre.org/techniques/T1499/"}]}],"version":1}\n', + exportDetails: '{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n', + }); + }); + + test('it does not export immutable rules', async () => { + const alertsClient = alertsClientMock.create(); + const result = getResult(); + result.params.immutable = true; + + const findResult: FindHit = { + page: 1, + perPage: 1, + total: 0, + data: [result], + }; + + alertsClient.get.mockResolvedValue(getResult()); + alertsClient.find.mockResolvedValue(findResult); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const objects = [{ rule_id: 'rule-1' }]; + const exports = await getExportByObjectIds(unsafeCast, objects); + expect(exports).toEqual({ + rulesNdjson: '', + exportDetails: + '{"exported_count":0,"missing_rules":[{"rule_id":"rule-1"}],"missing_rules_count":1}\n', + }); + }); + }); + + describe('getRulesFromObjects', () => { + test('it returns transformed rules from objects sent in', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.get.mockResolvedValue(getResult()); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const objects = [{ rule_id: 'rule-1' }]; + const exports = await getRulesFromObjects(unsafeCast, objects); + const expected: RulesErrors = { + missingRules: [], + rules: [ + { + created_at: '2019-12-13T16:40:33.400Z', + updated_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + rule_id: 'rule-1', + language: 'kuery', + output_index: '.siem-signals', + max_signals: 100, + risk_score: 50, + name: 'Detect Root/Admin Users', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + saved_id: 'some-id', + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + meta: { someMeta: 'someField' }, + severity: 'high', + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + threats: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + techniques: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + ], + version: 1, + }, + ], + }; + expect(exports).toEqual(expected); + }); + + test('it does not transform the rule if the rule is an immutable rule and designates it as a missing rule', async () => { + const alertsClient = alertsClientMock.create(); + const result = getResult(); + result.params.immutable = true; + + const findResult: FindHit = { + page: 1, + perPage: 1, + total: 0, + data: [result], + }; + + alertsClient.get.mockResolvedValue(result); + alertsClient.find.mockResolvedValue(findResult); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const objects = [{ rule_id: 'rule-1' }]; + const exports = await getRulesFromObjects(unsafeCast, objects); + const expected: RulesErrors = { + missingRules: [{ rule_id: 'rule-1' }], + rules: [], + }; + expect(exports).toEqual(expected); + }); + + test('it exports missing rules', async () => { + const alertsClient = alertsClientMock.create(); + + const findResult: FindHit = { + page: 1, + perPage: 1, + total: 0, + data: [], + }; + + alertsClient.get.mockRejectedValue({ output: { statusCode: 404 } }); + alertsClient.find.mockResolvedValue(findResult); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const objects = [{ rule_id: 'rule-1' }]; + const exports = await getRulesFromObjects(unsafeCast, objects); + const expected: RulesErrors = { + missingRules: [{ rule_id: 'rule-1' }], + rules: [], + }; + expect(exports).toEqual(expected); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts new file mode 100644 index 0000000000000..a5cf1bbfb7858 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertsClient } from '../../../../../alerting'; +import { getExportDetailsNdjson } from './get_export_details_ndjson'; +import { isAlertType } from '../rules/types'; +import { readRules } from './read_rules'; +import { transformRulesToNdjson, transformAlertToRule } from '../routes/rules/utils'; +import { OutputRuleAlertRest } from '../types'; + +export interface RulesErrors { + missingRules: Array<{ rule_id: string }>; + rules: Array>; +} + +export const getExportByObjectIds = async ( + alertsClient: AlertsClient, + objects: Array<{ rule_id: string }> +): Promise<{ + rulesNdjson: string; + exportDetails: string; +}> => { + const rulesAndErrors = await getRulesFromObjects(alertsClient, objects); + const rulesNdjson = transformRulesToNdjson(rulesAndErrors.rules); + const exportDetails = getExportDetailsNdjson(rulesAndErrors.rules, rulesAndErrors.missingRules); + return { rulesNdjson, exportDetails }; +}; + +export const getRulesFromObjects = async ( + alertsClient: AlertsClient, + objects: Array<{ rule_id: string }> +): Promise => { + const alertsAndErrors = await objects.reduce>( + async (accumPromise, object) => { + const accum = await accumPromise; + const rule = await readRules({ alertsClient, ruleId: object.rule_id }); + if (rule != null && isAlertType(rule) && rule.params.immutable !== true) { + const transformedRule = transformAlertToRule(rule); + return { + missingRules: accum.missingRules, + rules: [...accum.rules, transformedRule], + }; + } else { + return { + missingRules: [...accum.missingRules, { rule_id: object.rule_id }], + rules: accum.rules, + }; + } + }, + Promise.resolve({ + exportedCount: 0, + missingRules: [], + rules: [], + }) + ); + return alertsAndErrors; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.test.ts new file mode 100644 index 0000000000000..431b3776fd9e2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { sampleRule } from '../signals/__mocks__/es_results'; +import { getExportDetailsNdjson } from './get_export_details_ndjson'; + +describe('getExportDetailsNdjson', () => { + test('it ends with a new line character', () => { + const rule = sampleRule(); + const details = getExportDetailsNdjson([rule]); + expect(details.endsWith('\n')).toEqual(true); + }); + + test('it exports a correct count given a single rule and no missing rules', () => { + const rule = sampleRule(); + const details = getExportDetailsNdjson([rule]); + const reParsed = JSON.parse(details); + expect(reParsed).toEqual({ + exported_count: 1, + missing_rules: [], + missing_rules_count: 0, + }); + }); + + test('it exports a correct count given a no rules and a single missing rule', () => { + const missingRule = { rule_id: 'rule-1' }; + const details = getExportDetailsNdjson([], [missingRule]); + const reParsed = JSON.parse(details); + expect(reParsed).toEqual({ + exported_count: 0, + missing_rules: [{ rule_id: 'rule-1' }], + missing_rules_count: 1, + }); + }); + + test('it exports a correct count given multiple rules and multiple missing rules', () => { + const rule1 = sampleRule(); + const rule2 = sampleRule(); + rule2.rule_id = 'some other id'; + rule2.id = 'some other id'; + + const missingRule1 = { rule_id: 'rule-1' }; + const missingRule2 = { rule_id: 'rule-2' }; + + const details = getExportDetailsNdjson([rule1, rule2], [missingRule1, missingRule2]); + const reParsed = JSON.parse(details); + expect(reParsed).toEqual({ + exported_count: 2, + missing_rules: [missingRule1, missingRule2], + missing_rules_count: 2, + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.ts new file mode 100644 index 0000000000000..a39541d044bc3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.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 { OutputRuleAlertRest } from '../types'; + +export const getExportDetailsNdjson = ( + rules: Array>, + missingRules: Array<{ rule_id: string }> = [] +): string => { + const stringified = JSON.stringify({ + exported_count: rules.length, + missing_rules: missingRules, + missing_rules_count: missingRules.length, + }); + return `${stringified}\n`; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.ts index 9c83ae924486d..e8fa0b562bd24 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Alert } from '../../../../../alerting/server/types'; import { INTERNAL_RULE_ID_KEY } from '../../../../common/constants'; import { findRules } from './find_rules'; -import { RuleAlertType, ReadRuleParams, isAlertType } from './types'; +import { ReadRuleParams, isAlertType } from './types'; /** * This reads the rules through a cascade try of what is fastest to what is slowest. @@ -20,7 +21,7 @@ export const readRules = async ({ alertsClient, id, ruleId, -}: ReadRuleParams): Promise => { +}: ReadRuleParams): Promise => { if (id != null) { try { const rule = await alertsClient.get({ id }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts index 4f4c0da7127cd..4d9073e4b38d9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts @@ -5,6 +5,7 @@ */ import { get } from 'lodash/fp'; +import { Readable } from 'stream'; import { SIGNALS_ID } from '../../../../common/constants'; import { AlertsClient } from '../../../../../alerting/server/alerts_client'; @@ -47,6 +48,24 @@ export interface BulkRulesRequest extends RequestFacade { payload: RuleAlertParamsRest[]; } +export interface HapiReadableStream extends Readable { + hapi: { + filename: string; + }; +} +export interface ImportRulesRequest extends Omit { + query: { overwrite: boolean }; + payload: { file: HapiReadableStream }; +} + +export interface ExportRulesRequest extends Omit { + payload: { objects: Array<{ rule_id: string }> | null | undefined }; + query: { + file_name: string; + exclude_export_details: boolean; + }; +} + export type QueryRequest = Omit & { query: { id: string | undefined; rule_id: string | undefined }; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules.sh new file mode 100755 index 0000000000000..b46b5a0e80639 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +# +# 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. +# + +set -e +./check_env_variables.sh + +EXCLUDE_DETAILS=${1:-false} + +# Note: This file does not use jq on purpose for testing and pipe redirections + +# Example get all the rules except pre-packaged rules +# ./export_rules.sh + +# Example get the export details at the end +# ./export_rules.sh false + +curl -s -k \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id.sh new file mode 100755 index 0000000000000..bed9753b1b6f9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +# +# 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. +# + +set -e +./check_env_variables.sh + + +# Uses a default if no argument is specified +RULES=${1:-./rules/export/ruleid_queries.json} +EXCLUDE_DETAILS=${2:-false} + +# Note: This file does not use jq on purpose for testing and pipe redirections + +# Example get all the rules except pre-packaged rules +# ./export_rules_by_rule_id.sh + +# Example get the export details at the end +# ./export_rules_by_rule_id.sh ./rules/export/ruleid_queries.json false +curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS} \ + -d @${RULES} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id_to_file.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id_to_file.sh new file mode 100755 index 0000000000000..614024996cb39 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id_to_file.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +# +# 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. +# + +set -e +./check_env_variables.sh + +# Uses a defaults if no arguments are specified +RULES=${1:-./rules/export/ruleid_queries.json} +FILENAME=${2:-test.ndjson} +EXCLUDE_DETAILS=${3:-false} + +# Example export to the file named test.ndjson +# ./export_rules_by_rule_id_to_file.sh + +# Example export to the file named test.ndjson with export details appended +# ./export_rules_by_rule_id_to_file.sh ./rules/export/ruleid_queries.json test.ndjson false +curl -s -k -OJ \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS}&file_name=${FILENAME}" \ + -d @${RULES} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_to_file.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_to_file.sh new file mode 100755 index 0000000000000..a45e8036d004e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_to_file.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +# +# 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. +# + +set -e +./check_env_variables.sh + +FILENAME=${1:-test.ndjson} +EXCLUDE_DETAILS=${2:-false} + +# Example export to the file named test.ndjson +# ./export_rules_to_file.sh + +# Example export to the file named test.ndjson with export details appended +# ./export_rules_to_file.sh test.ndjson false +curl -s -k -OJ \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS}&file_name=${FILENAME}" diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules.sh new file mode 100755 index 0000000000000..5bb9ec589a7e3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +# +# 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. +# + +set -e +./check_env_variables.sh + +# Uses a defaults if no argument is specified +RULES=${1:-./rules/import/multiple_ruleid_queries.ndjson} +OVERWRITE=${2:-true} + +# Example to import and overwrite everything from ./rules/import/multiple_ruleid_queries.ndjson +# ./import_rules.sh + +# Example to not overwrite everything if it exists from ./rules/import/multiple_ruleid_queries.ndjson +# ./import_rules.sh ./rules/import/multiple_ruleid_queries.ndjson false +curl -s -k \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_import?overwrite=${OVERWRITE}" \ + --form file=@${RULES} \ + | jq .; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules_no_overwrite.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules_no_overwrite.sh new file mode 100755 index 0000000000000..dfc58bb5c1ab8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules_no_overwrite.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# +# 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. +# + +set -e +./check_env_variables.sh + +# Uses a default if no argument is specified +RULES=${1:-./rules/import/multiple_ruleid_queries.ndjson} + +# Example: ./import_rules_no_overwrite.sh +curl -s -k \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_import \ + --form file=@${RULES} \ + | jq .; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/export/ruleid_queries.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/export/ruleid_queries.json new file mode 100644 index 0000000000000..fabc37d9f5766 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/export/ruleid_queries.json @@ -0,0 +1,10 @@ +{ + "objects": [ + { + "rule_id": "query-rule-id-1" + }, + { + "rule_id": "query-rule-id-2" + } + ] +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/import/multiple_ruleid_queries.ndjson b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/import/multiple_ruleid_queries.ndjson new file mode 100644 index 0000000000000..a9de8b1e475a3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/import/multiple_ruleid_queries.ndjson @@ -0,0 +1,3 @@ +{"created_at":"2020-01-09T01:38:00.740Z","updated_at":"2020-01-09T01:38:00.740Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"6688f367-1aa2-4895-a5a8-b3701eecf57d","immutable":false,"interval":"5m","rule_id":"query-rule-id-1","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":1,"name":"Query with a rule id Number 1","query":"user.name: root or user.name: admin","references":[],"severity":"high","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1} +{"created_at":"2020-01-09T01:38:00.745Z","updated_at":"2020-01-09T01:38:00.745Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"7a912444-6cfa-4c8f-83f4-2b26fb2a2ed9","immutable":false,"interval":"5m","rule_id":"query-rule-id-2","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":2,"name":"Query with a rule id Number 2","query":"user.name: root or user.name: admin","references":[],"severity":"low","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1} +{"exported_count":2,"missing_rules":[],"missing_rules_count":0} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json index 46a2feeefd49e..e9d955f920571 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json @@ -21,7 +21,7 @@ } ], "enabled": false, - "immutable": true, + "immutable": false, "index": ["auditbeat-*", "filebeat-*"], "interval": "5m", "query": "user.name: root or user.name: admin", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/test_cases/multiple_ruleid_queries_corrupted.ndjson b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/test_cases/multiple_ruleid_queries_corrupted.ndjson new file mode 100644 index 0000000000000..94fc36ef6f7bf --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/test_cases/multiple_ruleid_queries_corrupted.ndjson @@ -0,0 +1,4 @@ +{"created_at":"2020-01-09T01:38:00.740Z","updated_at":"2020-01-09T01:38:00.740Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"6688f367-1aa2-4895-a5a8-b3701eecf57d","immutable":false,"interval":"5m","rule_id":"query-rule-id-1","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":1,"name":"Query with a rule id Number 1","query":"user.name: root or user.name: admin","references":[],"severity":"high","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1}, +{"created_at":"2020-01-09T01:38:00.745Z","updated_at":"2020-01-09T01:38:00.745Z","created_by":"elastic_kibana","enabled":true,"false_positives":[],"from":"now-6m","id":"7a912444-6cfa-4c8f-83f4-2b26fb2a2ed9","immutable":false,"interval":"5m","rule_id":"query-rule-id-2","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":2,"name":"Query with a rule id Number 2","query":"user.name: root or user.name: admin","references":[],"severity":"low","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1} +{"created_at":"2020-01-09T01:38:00.745Z","updated_at":"2020-01-09T01:38:00.745Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"7a912444-6cfa-4c8f-83f4-2b26fb2a2ed9","immutable":false,"interval":"5m","rule_id":"query-rule-id-3","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":2,"name":"Query with a rule id Number 2","query":"user.name: root or user.name: admin","references":[],"severity":"low","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1} +{"exported_count":2,"missing_rules":[],"missing_rules_count":0} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index ff0f2a8782cd2..968c7d9cb1cf0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -84,4 +84,9 @@ export type OutputRuleAlertRest = RuleAlertParamsRest & { updated_by: string | undefined | null; }; +export type ImportRuleAlertRest = Omit & { + id: string | undefined | null; + rule_id: string; +}; + export type CallWithRequest = (endpoint: string, params: T, options?: U) => Promise; From 68883c6333ed14ea630e24b9c134519ad55c46f0 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Thu, 9 Jan 2020 17:17:34 -0700 Subject: [PATCH 58/63] [SIEM][Detection Engine] pre-packaged rule changes and addition of one new rule ## Summary pre-packaged rule changes and addition of one new rule ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. ~~- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)~~ ~~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~~ ~~- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~~ ~~- [ ] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios~~ ~~- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~~ ### For maintainers ~~- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~ ~~- [ ] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~ --- .../rules/prepackaged_rules/index.ts | 2 ++ ..._chat_protocol_activity_to_the_internet.json | 2 +- ...mote_desktop_protocol_from_the_internet.json | 2 +- .../process_execution_via_wmi.json | 17 +++++++++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/process_execution_via_wmi.json diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts index 4e370bfdc5bc9..49b3c5d6802b4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts @@ -283,6 +283,7 @@ import rule273 from './splunk_detect_use_of_cmdexe_to_launch_script_interpreters import rule274 from './splunk_child_processes_of_spoolsvexe.json'; import rule275 from './splunk_detect_psexec_with_accepteula_flag.json'; import rule276 from './splunk_processes_created_by_netsh.json'; +import rule277 from './process_execution_via_wmi.json'; export const rawRules = [ rule1, @@ -561,4 +562,5 @@ export const rawRules = [ rule274, rule275, rule276, + rule277, ]; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json index dee04ee4fea8a..00976ea21cd44 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json @@ -9,7 +9,7 @@ "type": "query", "from": "now-6m", "to": "now", - "query": "(destination.port:6665 or destination.port:6666 or destination.port:6667 or destination.port:6668 or destination.port:6669) and not destination.ip:10.0.0.0/8 and not destination.ip:172.16.0.0/12 and not destination.ip:192.168.0.0/16", + "query": "(destination.port:20 or destination.port:21) and not destination.ip:10.0.0.0/8 and not destination.ip:172.16.0.0/12 and not destination.ip:192.168.0.0/16", "language": "kuery", "filters": [], "enabled": false, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json index c5a16bfef7248..69383d91ccbb9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json @@ -9,7 +9,7 @@ "type": "query", "from": "now-6m", "to": "now", - "query": "destination.port:3389 and not source.ip:10.0.0.0/8 and not source.ip:172.16.0.0/12 and not source.ip:192.168.0.0/16", + "query": "(destination.port:8080 or destination.port:3128) and not destination.ip:10.0.0.0/8 and not destination.ip:172.16.0.0/12 and not destination.ip:192.168.0.0/16", "language": "kuery", "filters": [], "enabled": false, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/process_execution_via_wmi.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/process_execution_via_wmi.json new file mode 100644 index 0000000000000..d6743c1ead4ac --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/process_execution_via_wmi.json @@ -0,0 +1,17 @@ +{ + "rule_id": "14ba7cd9-1489-459b-99a4-153c7a3f9abb", + "risk_score": 50, + "description": "Process Execution via WMI", + "immutable": true, + "interval": "5m", + "name": "Process Execution via WMI", + "severity": "low", + "type": "query", + "from": "now-6m", + "to": "now", + "query": "process.name:scrcons.exe", + "language": "kuery", + "filters": [], + "enabled": false, + "version": 1 +} From 482faae7995fd6febf3ce9ae200ab79476a36c26 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Thu, 9 Jan 2020 17:52:57 -0700 Subject: [PATCH 59/63] [SIEM] Adds Signals Histogram (#53742) ## Summary Detection Engine Meta Issue: #50405 This PR adds the `Signals Histogram` component for use on the main `Detection Engine` page, `Rule Details` page, and the newly designed `Overview` page. Out of the box configuration includes an `EuiSelect` for stacking by the following: * Risk Scores * Severities * Event Actions * Event Categories * Host Names * Rule Types * Rules * Users * Destination IPs * Source IPs Additional configuration properties are available to configure the component as needed depending on where it will be displayed (e.g. no `Stack By` option on `Overview`, filter to specific `rule_id` on `Rule Details`, etc): ``` ts interface SignalsHistogramPanelProps { defaultStackByOption?: SignalsHistogramOption; filters?: esFilters.Filter[]; from: number; query?: Query; legendPosition?: 'left' | 'right' | 'bottom' | 'top'; loadingInitial?: boolean; showLinkToSignals?: boolean; showTotalSignalsCount?: boolean; stackByOptions?: SignalsHistogramOption[]; title?: string; to: number; updateDateRange: (min: number, max: number) => void; } ``` ##### Light Theme: ![de_hist_light](https://user-images.githubusercontent.com/2946766/71299977-41685800-234e-11ea-93bd-05a0c4cb6ee1.gif) ##### Dark Theme: ![de_histogram_dark](https://user-images.githubusercontent.com/2946766/71299980-45947580-234e-11ea-9d26-380bae5c4aa6.gif) ##### Overview: Example props for overview impl: ``` jsx ``` ![image](https://user-images.githubusercontent.com/2946766/72030438-2fd7e900-3246-11ea-8404-40905ca5f85c.png) Note @andrew-goldstein @angorayc @MichaelMarcialis -- looks like the MITRE ATT&CK Tactics are stored as a nested object in `signal.rule.threat`, so we may have to do some finangling to get it to show on the histogram. e.g. format: ``` json { "framework": "MITRE ATT&CK", "tactic": { "id": "TA0010", "reference": "https://attack.mitre.org/tactics/TA0010", "name": "Exfiltration" }, "techniques": [ { "id": "T1002", "name": "Data Compressed", "reference": "https://attack.mitre.org/techniques/T1002" } ] } ``` ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. - [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials * Will work with @benskelker on any specific documentation - [ ] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios - [ ] ~This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~ ### For maintainers - [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~ - [ ] ~This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~ --- .../__snapshots__/index.test.tsx.snap | 3 - .../histogram_signals/index.test.tsx | 23 - .../histogram_signals/index.tsx | 79 ---- .../detection_engine/signals/api.ts | 2 +- .../detection_engine/signals/types.ts | 5 +- .../detection_engine/signals/use_query.tsx | 15 +- .../components/signals_chart/index.tsx | 41 -- .../signals_histogram_panel/config.ts | 21 + .../signals_histogram_panel/index.tsx | 112 +++++ .../signals_histogram/helpers.tsx | 73 +++ .../signals_histogram/index.tsx | 122 +++++ .../signals_histogram_panel/translations.ts | 116 +++++ .../signals_histogram_panel/types.ts | 40 ++ .../components/signals_info/index.tsx | 11 +- .../components/signals_info/query.dsl.ts | 2 +- .../detection_engine/detection_engine.tsx | 111 ++++- .../public/pages/detection_engine/index.tsx | 8 +- .../detection_engine/rules/details/index.tsx | 415 ++++++++++-------- 18 files changed, 836 insertions(+), 363 deletions(-) delete mode 100644 x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/__snapshots__/index.test.tsx.snap delete mode 100644 x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.test.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_chart/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/config.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/helpers.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/types.ts diff --git a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/__snapshots__/index.test.tsx.snap deleted file mode 100644 index d2579f427debe..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`HistogramSignals it renders 1`] = ``; diff --git a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.test.tsx deleted file mode 100644 index 5d2f3256ef509..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { shallow } from 'enzyme'; -import React from 'react'; - -import { TestProviders } from '../../../../mock'; -import { HistogramSignals } from './index'; - -describe('HistogramSignals', () => { - test('it renders', () => { - const wrapper = shallow( - - - - ); - - expect(wrapper.find('HistogramSignals')).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.tsx deleted file mode 100644 index 35fe8a2d90509..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.tsx +++ /dev/null @@ -1,79 +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 { - Axis, - Chart, - HistogramBarSeries, - Settings, - niceTimeFormatByDay, - timeFormatter, -} from '@elastic/charts'; -import React from 'react'; -import { npStart } from 'ui/new_platform'; - -export const HistogramSignals = React.memo(() => { - const sampleChartData = [ - { x: 1571090784000, y: 2, a: 'a' }, - { x: 1571090784000, y: 2, b: 'b' }, - { x: 1571093484000, y: 7, a: 'a' }, - { x: 1571096184000, y: 3, a: 'a' }, - { x: 1571098884000, y: 2, a: 'a' }, - { x: 1571101584000, y: 7, a: 'a' }, - { x: 1571104284000, y: 3, a: 'a' }, - { x: 1571106984000, y: 2, a: 'a' }, - { x: 1571109684000, y: 7, a: 'a' }, - { x: 1571112384000, y: 3, a: 'a' }, - { x: 1571115084000, y: 2, a: 'a' }, - { x: 1571117784000, y: 7, a: 'a' }, - { x: 1571120484000, y: 3, a: 'a' }, - { x: 1571123184000, y: 2, a: 'a' }, - { x: 1571125884000, y: 7, a: 'a' }, - { x: 1571128584000, y: 3, a: 'a' }, - { x: 1571131284000, y: 2, a: 'a' }, - { x: 1571133984000, y: 7, a: 'a' }, - { x: 1571136684000, y: 3, a: 'a' }, - { x: 1571139384000, y: 2, a: 'a' }, - { x: 1571142084000, y: 7, a: 'a' }, - { x: 1571144784000, y: 3, a: 'a' }, - { x: 1571147484000, y: 2, a: 'a' }, - { x: 1571150184000, y: 7, a: 'a' }, - { x: 1571152884000, y: 3, a: 'a' }, - { x: 1571155584000, y: 2, a: 'a' }, - { x: 1571158284000, y: 7, a: 'a' }, - { x: 1571160984000, y: 3, a: 'a' }, - { x: 1571163684000, y: 2, a: 'a' }, - { x: 1571166384000, y: 7, a: 'a' }, - { x: 1571169084000, y: 3, a: 'a' }, - { x: 1571171784000, y: 2, a: 'a' }, - { x: 1571174484000, y: 7, a: 'a' }, - ]; - - return ( - - - - - - - - - - ); -}); -HistogramSignals.displayName = 'HistogramSignals'; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts index e7641fd37678e..8754d73637e7c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts @@ -42,7 +42,7 @@ export const fetchQuerySignals = async ({ 'content-type': 'application/json', 'kbn-xsrf': 'true', }, - body: query, + body: JSON.stringify(query), signal, }); await throwIfNotOk(response); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts index 32f53691bae87..34cb7684a0399 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts @@ -10,7 +10,7 @@ export interface BasicSignals { signal: AbortSignal; } export interface QuerySignals extends BasicSignals { - query: string; + query: object; } export interface SignalsResponse { @@ -18,7 +18,8 @@ export interface SignalsResponse { timeout: boolean; } -export interface SignalSearchResponse extends SignalsResponse { +export interface SignalSearchResponse + extends SignalsResponse { _shards: { total: number; successful: number; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx index 65a5ac866e68d..fa88a84fb1187 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx @@ -4,20 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useState } from 'react'; +import React, { SetStateAction, useEffect, useState } from 'react'; import { fetchQuerySignals } from './api'; import { SignalSearchResponse } from './types'; -type Return = [boolean, SignalSearchResponse | null]; +type Return = [ + boolean, + SignalSearchResponse | null, + React.Dispatch> +]; /** * Hook for using to get a Signals from the Detection Engine API * - * @param query convert a dsl into string + * @param initialQuery query dsl object * */ -export const useQuerySignals = (query: string): Return => { +export const useQuerySignals = (initialQuery: object): Return => { + const [query, setQuery] = useState(initialQuery); const [signals, setSignals] = useState | null>(null); const [loading, setLoading] = useState(true); @@ -53,5 +58,5 @@ export const useQuerySignals = (query: string): Return => }; }, [query]); - return [loading, signals]; + return [loading, signals, setQuery]; }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_chart/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_chart/index.tsx deleted file mode 100644 index 01ebafdccfefd..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_chart/index.tsx +++ /dev/null @@ -1,41 +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 { EuiPanel, EuiSelect } from '@elastic/eui'; -import { noop } from 'lodash/fp'; -import React, { memo } from 'react'; - -import { HeaderSection } from '../../../../components/header_section'; -import { HistogramSignals } from '../../../../components/page/detection_engine/histogram_signals'; - -export const sampleChartOptions = [ - { text: 'Risk scores', value: 'risk_scores' }, - { text: 'Severities', value: 'severities' }, - { text: 'Top destination IPs', value: 'destination_ips' }, - { text: 'Top event actions', value: 'event_actions' }, - { text: 'Top event categories', value: 'event_categories' }, - { text: 'Top host names', value: 'host_names' }, - { text: 'Top rule types', value: 'rule_types' }, - { text: 'Top rules', value: 'rules' }, - { text: 'Top source IPs', value: 'source_ips' }, - { text: 'Top users', value: 'users' }, -]; - -const SignalsChartsComponent = () => ( - - - noop} - prepend="Stack by" - value={sampleChartOptions[0].value} - /> - - - - -); - -export const SignalsCharts = memo(SignalsChartsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/config.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/config.ts new file mode 100644 index 0000000000000..f329780b075e3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/config.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 * as i18n from './translations'; +import { SignalsHistogramOption } from './types'; + +export const signalsHistogramOptions: SignalsHistogramOption[] = [ + { text: i18n.STACK_BY_RISK_SCORES, value: 'signal.rule.risk_score' }, + { text: i18n.STACK_BY_SEVERITIES, value: 'signal.rule.severity' }, + { text: i18n.STACK_BY_DESTINATION_IPS, value: 'destination.ip' }, + { text: i18n.STACK_BY_ACTIONS, value: 'event.action' }, + { text: i18n.STACK_BY_CATEGORIES, value: 'event.category' }, + { text: i18n.STACK_BY_HOST_NAMES, value: 'host.name' }, + { text: i18n.STACK_BY_RULE_TYPES, value: 'signal.rule.type' }, + { text: i18n.STACK_BY_RULE_NAMES, value: 'signal.rule.name' }, + { text: i18n.STACK_BY_SOURCE_IPS, value: 'source.ip' }, + { text: i18n.STACK_BY_USERS, value: 'user.name' }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx new file mode 100644 index 0000000000000..fda40f5f9fa5d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Position } from '@elastic/charts'; +import { EuiButton, EuiPanel, EuiSelect } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import React, { memo, useCallback, useMemo, useState } from 'react'; + +import { HeaderSection } from '../../../../components/header_section'; +import { SignalsHistogram } from './signals_histogram'; + +import * as i18n from './translations'; +import { Query } from '../../../../../../../../../src/plugins/data/common/query'; +import { esFilters } from '../../../../../../../../../src/plugins/data/common/es_query'; +import { SignalsHistogramOption, SignalsTotal } from './types'; +import { signalsHistogramOptions } from './config'; +import { getDetectionEngineUrl } from '../../../../components/link_to'; +import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants'; +import { useUiSetting$ } from '../../../../lib/kibana'; + +const defaultTotalSignalsObj: SignalsTotal = { + value: 0, + relation: 'eq', +}; + +interface SignalsHistogramPanelProps { + defaultStackByOption?: SignalsHistogramOption; + filters?: esFilters.Filter[]; + from: number; + query?: Query; + legendPosition?: Position; + loadingInitial?: boolean; + showLinkToSignals?: boolean; + showTotalSignalsCount?: boolean; + stackByOptions?: SignalsHistogramOption[]; + title?: string; + to: number; + updateDateRange: (min: number, max: number) => void; +} + +export const SignalsHistogramPanel = memo( + ({ + defaultStackByOption = signalsHistogramOptions[0], + filters, + query, + from, + legendPosition = 'bottom', + loadingInitial = false, + showLinkToSignals = false, + showTotalSignalsCount = false, + stackByOptions, + to, + title = i18n.HISTOGRAM_HEADER, + updateDateRange, + }) => { + const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const [totalSignalsObj, setTotalSignalsObj] = useState(defaultTotalSignalsObj); + const [selectedStackByOption, setSelectedStackByOption] = useState( + defaultStackByOption + ); + + const totalSignals = useMemo( + () => + i18n.SHOWING_SIGNALS( + numeral(totalSignalsObj.value).format(defaultNumberFormat), + totalSignalsObj.value, + totalSignalsObj.relation === 'gte' ? '>' : totalSignalsObj.relation === 'lte' ? '<' : '' + ), + [totalSignalsObj] + ); + + const setSelectedOptionCallback = useCallback((event: React.ChangeEvent) => { + setSelectedStackByOption( + stackByOptions?.find(co => co.value === event.target.value) ?? defaultStackByOption + ); + }, []); + + return ( + + + {stackByOptions && ( + + )} + {showLinkToSignals && ( + {i18n.VIEW_SIGNALS} + )} + + + + + ); + } +); + +SignalsHistogramPanel.displayName = 'SignalsHistogramPanel'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/helpers.tsx new file mode 100644 index 0000000000000..ed503e9872f0a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/helpers.tsx @@ -0,0 +1,73 @@ +/* + * 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 { HistogramData, SignalsAggregation, SignalsBucket, SignalsGroupBucket } from '../types'; +import { SignalSearchResponse } from '../../../../../containers/detection_engine/signals/types'; +import * as i18n from '../translations'; + +export const formatSignalsData = ( + signalsData: SignalSearchResponse<{}, SignalsAggregation> | null +) => { + const groupBuckets: SignalsGroupBucket[] = + signalsData?.aggregations?.signalsByGrouping?.buckets ?? []; + return groupBuckets.reduce((acc, { key: group, signals }) => { + const signalsBucket: SignalsBucket[] = signals.buckets ?? []; + + return [ + ...acc, + ...signalsBucket.map(({ key, doc_count }: SignalsBucket) => ({ + x: key, + y: doc_count, + g: group, + })), + ]; + }, []); +}; + +export const getSignalsHistogramQuery = ( + stackByField: string, + from: number, + to: number, + additionalFilters: Array<{ + bool: { filter: unknown[]; should: unknown[]; must_not: unknown[]; must: unknown[] }; + }> +) => ({ + aggs: { + signalsByGrouping: { + terms: { + field: stackByField, + missing: stackByField.endsWith('.ip') ? '0.0.0.0' : i18n.ALL_OTHERS, + order: { + _count: 'desc', + }, + size: 10, + }, + aggs: { + signals: { + auto_date_histogram: { + field: '@timestamp', + buckets: 36, + }, + }, + }, + }, + }, + query: { + bool: { + filter: [ + ...additionalFilters, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/index.tsx new file mode 100644 index 0000000000000..218fcc3a70f79 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/index.tsx @@ -0,0 +1,122 @@ +/* + * 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 { + Axis, + Chart, + getAxisId, + getSpecId, + HistogramBarSeries, + niceTimeFormatByDay, + Position, + Settings, + timeFormatter, +} from '@elastic/charts'; +import React, { useEffect, useMemo } from 'react'; +import { EuiLoadingContent } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import { useQuerySignals } from '../../../../../containers/detection_engine/signals/use_query'; +import { Query } from '../../../../../../../../../../src/plugins/data/common/query'; +import { esFilters, esQuery } from '../../../../../../../../../../src/plugins/data/common/es_query'; +import { SignalsAggregation, SignalsTotal } from '../types'; +import { formatSignalsData, getSignalsHistogramQuery } from './helpers'; +import { useTheme } from '../../../../../components/charts/common'; +import { useKibana } from '../../../../../lib/kibana'; + +interface HistogramSignalsProps { + filters?: esFilters.Filter[]; + from: number; + legendPosition?: Position; + loadingInitial: boolean; + query?: Query; + setTotalSignalsCount: React.Dispatch; + stackByField: string; + to: number; + updateDateRange: (min: number, max: number) => void; +} + +export const SignalsHistogram = React.memo( + ({ + to, + from, + query, + filters, + legendPosition = 'bottom', + loadingInitial, + setTotalSignalsCount, + stackByField, + updateDateRange, + }) => { + const [isLoadingSignals, signalsData, setQuery] = useQuerySignals<{}, SignalsAggregation>( + getSignalsHistogramQuery(stackByField, from, to, []) + ); + const theme = useTheme(); + const kibana = useKibana(); + + const formattedSignalsData = useMemo(() => formatSignalsData(signalsData), [signalsData]); + + useEffect(() => { + setTotalSignalsCount( + signalsData?.hits.total ?? { + value: 0, + relation: 'eq', + } + ); + }, [signalsData]); + + useEffect(() => { + const converted = esQuery.buildEsQuery( + undefined, + query != null ? [query] : [], + filters?.filter(f => f.meta.disabled === false) ?? [], + { + ...esQuery.getEsQueryConfig(kibana.services.uiSettings), + dateFormatTZ: undefined, + } + ); + + setQuery( + getSignalsHistogramQuery(stackByField, from, to, !isEmpty(converted) ? [converted] : []) + ); + }, [stackByField, from, to, query, filters]); + + return ( + <> + {loadingInitial || isLoadingSignals ? ( + + ) : ( + + + + + + + + + + )} + + ); + } +); +SignalsHistogram.displayName = 'SignalsHistogram'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts new file mode 100644 index 0000000000000..0245b9968cc36 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts @@ -0,0 +1,116 @@ +/* + * 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 STACK_BY_LABEL = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.stackByLabel', + { + defaultMessage: 'Stack by', + } +); + +export const STACK_BY_RISK_SCORES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.riskScoresDropDown', + { + defaultMessage: 'Risk scores', + } +); + +export const STACK_BY_SEVERITIES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.severitiesDropDown', + { + defaultMessage: 'Severities', + } +); + +export const STACK_BY_DESTINATION_IPS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.destinationIpsDropDown', + { + defaultMessage: 'Top destination IPs', + } +); + +export const STACK_BY_SOURCE_IPS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.sourceIpsDropDown', + { + defaultMessage: 'Top source IPs', + } +); + +export const STACK_BY_ACTIONS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.eventActionsDropDown', + { + defaultMessage: 'Top event actions', + } +); + +export const STACK_BY_CATEGORIES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.eventCategoriesDropDown', + { + defaultMessage: 'Top event categories', + } +); + +export const STACK_BY_HOST_NAMES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.hostNamesDropDown', + { + defaultMessage: 'Top host names', + } +); + +export const STACK_BY_RULE_TYPES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.ruleTypesDropDown', + { + defaultMessage: 'Top rule types', + } +); + +export const STACK_BY_RULE_NAMES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.rulesDropDown', + { + defaultMessage: 'Top rules', + } +); + +export const STACK_BY_USERS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.usersDropDown', + { + defaultMessage: 'Top users', + } +); + +export const HISTOGRAM_HEADER = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.headerTitle', + { + defaultMessage: 'Signal detection frequency', + } +); + +export const ALL_OTHERS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.allOthersGroupingLabel', + { + defaultMessage: 'All others', + } +); + +export const VIEW_SIGNALS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.viewSignalsButtonLabel', + { + defaultMessage: 'View signals', + } +); + +export const SHOWING_SIGNALS = ( + totalSignalsFormatted: string, + totalSignals: number, + modifier: string +) => + i18n.translate('xpack.siem.detectionEngine.signals.histogram.showingSignalsTitle', { + values: { totalSignalsFormatted, totalSignals, modifier }, + defaultMessage: + 'Showing: {modifier}{totalSignalsFormatted} {totalSignals, plural, =1 {signal} other {signals}}', + }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/types.ts new file mode 100644 index 0000000000000..4eb10852450ad --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/types.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface SignalsHistogramOption { + text: string; + value: string; +} + +export interface HistogramData { + x: number; + y: number; + g: string; +} + +export interface SignalsAggregation { + signalsByGrouping: { + buckets: SignalsGroupBucket[]; + }; +} + +export interface SignalsBucket { + key_as_string: string; + key: number; + doc_count: number; +} + +export interface SignalsGroupBucket { + key: string; + signals: { + buckets: SignalsBucket[]; + }; +} + +export interface SignalsTotal { + value: number; + relation: string; +} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx index a90022d4a34ce..fc1110e382847 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx @@ -9,7 +9,7 @@ import { FormattedRelative } from '@kbn/i18n/react'; import React, { useState, useEffect } from 'react'; import { useQuerySignals } from '../../../../containers/detection_engine/signals/use_query'; -import { buildlastSignalsQuery } from './query.dsl'; +import { buildLastSignalsQuery } from './query.dsl'; import { Aggs } from './types'; interface SignalInfo { @@ -26,14 +26,7 @@ export const useSignalInfo = ({ ruleId = null }: SignalInfo): Return => { ); - let query = ''; - try { - query = JSON.stringify(buildlastSignalsQuery(ruleId)); - } catch { - query = ''; - } - - const [loading, signals] = useQuerySignals(query); + const [loading, signals] = useQuerySignals(buildLastSignalsQuery(ruleId)); useEffect(() => { if (signals != null) { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts index 0b14aa17a9450..8cb07a4f8e6b5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export const buildlastSignalsQuery = (ruleId: string | undefined | null) => { +export const buildLastSignalsQuery = (ruleId: string | undefined | null) => { const queryFilter = [ { bool: { should: [{ match: { 'signal.status': 'open' } }], minimum_should_match: 1 }, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx index 8e5c3e9f13118..2a91a559ec0e4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiSpacer, EuiPanel, EuiLoadingContent } from '@elastic/eui'; -import React from 'react'; +import { EuiButton, EuiLoadingContent, EuiPanel, EuiSpacer } from '@elastic/eui'; +import React, { useCallback } from 'react'; import { StickyContainer } from 'react-sticky'; +import { connect } from 'react-redux'; +import { ActionCreator } from 'typescript-fsa'; import { FiltersGlobal } from '../../components/filters_global'; import { HeaderPage } from '../../components/header_page'; import { SiemSearchBar } from '../../components/search_bar'; @@ -18,24 +20,63 @@ import { SpyRoute } from '../../utils/route/spy_routes'; import { SignalsTable } from './components/signals'; import * as signalsI18n from './components/signals/translations'; -import { SignalsCharts } from './components/signals_chart'; +import { SignalsHistogramPanel } from './components/signals_histogram_panel'; +import { Query } from '../../../../../../../src/plugins/data/common/query'; +import { esFilters } from '../../../../../../../src/plugins/data/common/es_query'; +import { inputsSelectors } from '../../store/inputs'; +import { State } from '../../store'; +import { InputsRange } from '../../store/inputs/model'; +import { signalsHistogramOptions } from './components/signals_histogram_panel/config'; import { useSignalInfo } from './components/signals_info'; import { DetectionEngineEmptyPage } from './detection_engine_empty_page'; import { DetectionEngineNoIndex } from './detection_engine_no_signal_index'; import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated'; import * as i18n from './translations'; import { HeaderSection } from '../../components/header_section'; +import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions'; +import { InputsModelId } from '../../store/inputs/constants'; -interface DetectionEngineComponentProps { +interface OwnProps { loading: boolean; isSignalIndexExists: boolean | null; isUserAuthenticated: boolean | null; signalsIndex: string | null; } +interface ReduxProps { + filters: esFilters.Filter[]; + query: Query; +} + +export interface DispatchProps { + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: number; + to: number; + }>; +} + +type DetectionEngineComponentProps = OwnProps & ReduxProps & DispatchProps; + export const DetectionEngineComponent = React.memo( - ({ loading, isSignalIndexExists, isUserAuthenticated, signalsIndex }) => { + ({ + filters, + loading, + isSignalIndexExists, + isUserAuthenticated, + query, + setAbsoluteRangeDatePicker, + signalsIndex, + }) => { const [lastSignals] = useSignalInfo({}); + + const updateDateRangeCallback = useCallback( + (min: number, max: number) => { + setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); + }, + [setAbsoluteRangeDatePicker] + ); + if (isUserAuthenticated != null && !isUserAuthenticated && !loading) { return ( @@ -81,22 +122,33 @@ export const DetectionEngineComponent = React.memo - - - - {({ to, from }) => - !loading ? ( - isSignalIndexExists && ( - - ) - ) : ( - - - - - ) - } + {({ to, from }) => ( + <> + + + + + {!loading ? ( + isSignalIndexExists && ( + + ) + ) : ( + + + + + )} + + )} @@ -115,3 +167,22 @@ export const DetectionEngineComponent = React.memo { + const getGlobalInputs = inputsSelectors.globalSelector(); + return (state: State) => { + const globalInputs: InputsRange = getGlobalInputs(state); + const { query, filters } = globalInputs; + + return { + query, + filters, + }; + }; +}; + +export const DetectionEngine = connect(makeMapStateToProps, { + setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker, +})(DetectionEngineComponent); + +DetectionEngine.displayName = 'DetectionEngine'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx index 9c95c74cd62a5..c32cab7f933b2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx @@ -11,9 +11,9 @@ import { useSignalIndex } from '../../containers/detection_engine/signals/use_si import { usePrivilegeUser } from '../../containers/detection_engine/signals/use_privilege_user'; import { CreateRuleComponent } from './rules/create'; -import { DetectionEngineComponent } from './detection_engine'; +import { DetectionEngine } from './detection_engine'; import { EditRuleComponent } from './rules/edit'; -import { RuleDetailsComponent } from './rules/details'; +import { RuleDetails } from './rules/details'; import { RulesComponent } from './rules'; const detectionEnginePath = `/:pageName(detection-engine)`; @@ -44,7 +44,7 @@ export const DetectionEngineContainer = React.memo(() => { return ( - (() => { - + diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 4d887c7cb5b6e..9b6998ab4a132 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -6,10 +6,12 @@ import { EuiButton, EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { memo, useMemo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { StickyContainer } from 'react-sticky'; +import { ActionCreator } from 'typescript-fsa'; +import { connect } from 'react-redux'; import { FiltersGlobal } from '../../../../components/filters_global'; import { FormattedDate } from '../../../../components/formatted_date'; import { HeaderPage } from '../../../../components/header_page'; @@ -24,7 +26,7 @@ import { } from '../../../../containers/source'; import { SpyRoute } from '../../../../utils/route/spy_routes'; -import { SignalsCharts } from '../../components/signals_chart'; +import { SignalsHistogramPanel } from '../../components/signals_histogram_panel'; import { SignalsTable } from '../../components/signals'; import { DetectionEngineEmptyPage } from '../../detection_engine_empty_page'; import { useSignalInfo } from '../../components/signals_info'; @@ -39,198 +41,261 @@ import { getStepsData } from '../helpers'; import * as ruleI18n from '../translations'; import * as i18n from './translations'; import { GlobalTime } from '../../../../containers/global_time'; +import { signalsHistogramOptions } from '../../components/signals_histogram_panel/config'; +import { InputsModelId } from '../../../../store/inputs/constants'; +import { esFilters } from '../../../../../../../../../src/plugins/data/common/es_query'; +import { Query } from '../../../../../../../../../src/plugins/data/common/query'; +import { inputsSelectors } from '../../../../store/inputs'; +import { State } from '../../../../store'; +import { InputsRange } from '../../../../store/inputs/model'; +import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../../store/inputs/actions'; -interface RuleDetailsComponentProps { +interface OwnProps { signalsIndex: string | null; } -export const RuleDetailsComponent = memo(({ signalsIndex }) => { - const { ruleId } = useParams(); - const [loading, rule] = useRule(ruleId); - const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ - rule, - detailsView: true, - }); - const [lastSignals] = useSignalInfo({ ruleId }); - - const title = loading === true || rule === null ? : rule.name; - const subTitle = useMemo( - () => - loading === true || rule === null ? ( - - ) : ( - [ - - ), - }} - />, - rule?.updated_by != null ? ( +interface ReduxProps { + filters: esFilters.Filter[]; + query: Query; +} + +export interface DispatchProps { + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: number; + to: number; + }>; +} + +type RuleDetailsComponentProps = OwnProps & ReduxProps & DispatchProps; + +const RuleDetailsComponent = memo( + ({ filters, query, setAbsoluteRangeDatePicker, signalsIndex }) => { + const { ruleId } = useParams(); + const [loading, rule] = useRule(ruleId); + const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ + rule, + detailsView: true, + }); + const [lastSignals] = useSignalInfo({ ruleId }); + + const title = loading === true || rule === null ? : rule.name; + const subTitle = useMemo( + () => + loading === true || rule === null ? ( + + ) : ( + [ ), }} - /> - ) : ( - '' - ), - ] - ), - [loading, rule] - ); - - const signalDefaultFilters = useMemo( - () => (ruleId != null ? buildSignalsRuleIdFilter(ruleId) : []), - [ruleId] - ); - return ( - <> - - {({ indicesExist, indexPattern }) => { - return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( - - {({ to, from }) => ( - - - - - - - - {detectionI18n.LAST_SIGNAL} - {': '} - {lastSignals} - - ) : null, - 'Status: Comming Soon', - ]} - title={title} - > - - - - + />, + rule?.updated_by != null ? ( + + ), + }} + /> + ) : ( + '' + ), + ] + ), + [loading, rule] + ); - - - - - {ruleI18n.EDIT_RULE_SETTINGS} - - - - - - - - - - - - - {defineRuleData != null && ( - - )} - - - - - - {aboutRuleData != null && ( - - )} - - - - - - {scheduleRuleData != null && ( - (ruleId != null ? buildSignalsRuleIdFilter(ruleId) : []), + [ruleId] + ); + + const signalMergedFilters = useMemo(() => [...signalDefaultFilters, ...filters], [ + signalDefaultFilters, + filters, + ]); + + const updateDateRangeCallback = useCallback( + (min: number, max: number) => { + setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); + }, + [setAbsoluteRangeDatePicker] + ); + + return ( + <> + + {({ indicesExist, indexPattern }) => { + return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( + + {({ to, from }) => ( + + + + + + + + {detectionI18n.LAST_SIGNAL} + {': '} + {lastSignals} + + ) : null, + 'Status: Comming Soon', + ]} + title={title} + > + + + - )} - - - + - + + + + + {ruleI18n.EDIT_RULE_SETTINGS} + + + + + + - + - + + + + {defineRuleData != null && ( + + )} + + + + + + {aboutRuleData != null && ( + + )} + + - {ruleId != null && ( - + + {scheduleRuleData != null && ( + + )} + + + + + + - )} - - - )} - - ) : ( - - - - - - ); - }} - - - - - ); -}); + + + + {ruleId != null && ( + + )} + + + )} + + ) : ( + + + + + + ); + }} + + + + + ); + } +); RuleDetailsComponent.displayName = 'RuleDetailsComponent'; + +const makeMapStateToProps = () => { + const getGlobalInputs = inputsSelectors.globalSelector(); + return (state: State) => { + const globalInputs: InputsRange = getGlobalInputs(state); + const { query, filters } = globalInputs; + + return { + query, + filters, + }; + }; +}; + +export const RuleDetails = connect(makeMapStateToProps, { + setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker, +})(RuleDetailsComponent); + +RuleDetails.displayName = 'RuleDetails'; From a1b310d161f4942ec98f04f7a0fcd64892ecd77f Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 10 Jan 2020 02:20:41 +0000 Subject: [PATCH 60/63] chore(NA): add exception for timelion server function folders on clean dll logic (#54086) Co-authored-by: Elastic Machine --- .../tasks/nodejs_modules/clean_client_modules_on_dll_task.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js b/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js index 27335b35374ba..19d74bcf89e30 100644 --- a/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js +++ b/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js @@ -48,6 +48,8 @@ export const CleanClientModulesOnDLLTask = { ]; const discoveredLegacyCorePluginEntries = await globby([ `${baseDir}/src/legacy/core_plugins/*/index.js`, + // Small exception to load dynamically discovered functions for timelion plugin + `${baseDir}/src/legacy/core_plugins/timelion/server/*_functions/**/*.js`, `!${baseDir}/src/legacy/core_plugins/**/public`, ]); const discoveredPluginEntries = await globby([ From bbf703a0ff46395114947d15481384ad4ddbc071 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Fri, 10 Jan 2020 10:46:37 +0300 Subject: [PATCH 61/63] tighten SO repository type (#54328) * tighten SO repository type * update docs Co-authored-by: Elastic Machine --- .../core/server/kibana-plugin-server.icustomclusterclient.md | 2 +- docs/development/core/server/kibana-plugin-server.md | 2 +- src/core/server/saved_objects/service/lib/repository.ts | 2 +- src/core/server/server.api.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/development/core/server/kibana-plugin-server.icustomclusterclient.md b/docs/development/core/server/kibana-plugin-server.icustomclusterclient.md index bc9ea71bc2389..d7511a119fc0f 100644 --- a/docs/development/core/server/kibana-plugin-server.icustomclusterclient.md +++ b/docs/development/core/server/kibana-plugin-server.icustomclusterclient.md @@ -4,7 +4,7 @@ ## ICustomClusterClient type -Represents an Elasticsearch cluster API client created by a plugin.. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). +Represents an Elasticsearch cluster API client created by a plugin. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). See [ClusterClient](./kibana-plugin-server.clusterclient.md). diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 3f01048846fe1..5e28643843af3 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -177,7 +177,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IBasePath](./kibana-plugin-server.ibasepath.md) | Access or manipulate the Kibana base path[BasePath](./kibana-plugin-server.basepath.md) | | [IClusterClient](./kibana-plugin-server.iclusterclient.md) | Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-server.clusterclient.md). | | [IContextProvider](./kibana-plugin-server.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | -| [ICustomClusterClient](./kibana-plugin-server.icustomclusterclient.md) | Represents an Elasticsearch cluster API client created by a plugin.. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-server.clusterclient.md). | +| [ICustomClusterClient](./kibana-plugin-server.icustomclusterclient.md) | Represents an Elasticsearch cluster API client created by a plugin. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-server.clusterclient.md). | | [IsAuthenticated](./kibana-plugin-server.isauthenticated.md) | Return authentication status for a request. | | [ISavedObjectsRepository](./kibana-plugin-server.isavedobjectsrepository.md) | See [SavedObjectsRepository](./kibana-plugin-server.savedobjectsrepository.md) | | [IScopedClusterClient](./kibana-plugin-server.iscopedclusterclient.md) | Serves the same purpose as "normal" ClusterClient but exposes additional callAsCurrentUser method that doesn't use credentials of the Kibana internal user (as callAsInternalUser does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API.See [ScopedClusterClient](./kibana-plugin-server.scopedclusterclient.md). | diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index ef5bcecc948a9..70b8b2878c15b 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -141,7 +141,7 @@ export class SavedObjectsRepository { callCluster: APICaller, extraTypes: string[] = [], injectedConstructor: any = SavedObjectsRepository - ) { + ): ISavedObjectsRepository { const mappings = migrator.getActiveMappings(); const allTypes = Object.keys(getRootPropertiesObjects(mappings)); const serializer = new SavedObjectsSerializer(schema); diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 72934175f96e5..c4f3bf6caf5bd 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1796,7 +1796,7 @@ export class SavedObjectsRepository { // Warning: (ae-forgotten-export) The symbol "KibanaMigrator" needs to be exported by the entry point index.d.ts // // @internal - static createRepository(migrator: KibanaMigrator, schema: SavedObjectsSchema, config: LegacyConfig, indexName: string, callCluster: APICaller, extraTypes?: string[], injectedConstructor?: any): any; + static createRepository(migrator: KibanaMigrator, schema: SavedObjectsSchema, config: LegacyConfig, indexName: string, callCluster: APICaller, extraTypes?: string[], injectedConstructor?: any): ISavedObjectsRepository; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; // (undocumented) From 599af6a8e65a5871b43b4cdf9f2118a443c7c092 Mon Sep 17 00:00:00 2001 From: Chris Mark Date: Fri, 10 Jan 2020 11:04:19 +0200 Subject: [PATCH 62/63] [Home][Tutorial] Add STAN data UI (#54102) --- .../home/tutorial_resources/logos/stan.svg | 1 + .../kibana/server/tutorials/register.js | 2 + .../server/tutorials/stan_metrics/index.js | 60 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/stan.svg create mode 100644 src/legacy/core_plugins/kibana/server/tutorials/stan_metrics/index.js diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/stan.svg b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/stan.svg new file mode 100644 index 0000000000000..5a1d6e9a52f17 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/stan.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/legacy/core_plugins/kibana/server/tutorials/register.js b/src/legacy/core_plugins/kibana/server/tutorials/register.js index ecc2d1df8c388..53ec16c1ca593 100644 --- a/src/legacy/core_plugins/kibana/server/tutorials/register.js +++ b/src/legacy/core_plugins/kibana/server/tutorials/register.js @@ -83,6 +83,7 @@ import { awsLogsSpecProvider } from './aws_logs'; import { activemqLogsSpecProvider } from './activemq_logs'; import { activemqMetricsSpecProvider } from './activemq_metrics'; import { azureMetricsSpecProvider } from './azure_metrics'; +import { stanMetricsSpecProvider } from './stan_metrics'; import { envoyproxyMetricsSpecProvider } from './envoyproxy_metrics'; export function registerTutorials(server) { @@ -155,5 +156,6 @@ export function registerTutorials(server) { server.newPlatform.setup.plugins.home.tutorials.registerTutorial(activemqLogsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(activemqMetricsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(azureMetricsSpecProvider); + server.newPlatform.setup.plugins.home.tutorials.registerTutorial(stanMetricsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(envoyproxyMetricsSpecProvider); } diff --git a/src/legacy/core_plugins/kibana/server/tutorials/stan_metrics/index.js b/src/legacy/core_plugins/kibana/server/tutorials/stan_metrics/index.js new file mode 100644 index 0000000000000..3f5817ce2890b --- /dev/null +++ b/src/legacy/core_plugins/kibana/server/tutorials/stan_metrics/index.js @@ -0,0 +1,60 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; +import { + onPremInstructions, + cloudInstructions, + onPremCloudInstructions, +} from '../../../common/tutorials/metricbeat_instructions'; + +export function stanMetricsSpecProvider(context) { + const moduleName = 'stan'; + return { + id: 'stanMetrics', + name: i18n.translate('kbn.server.tutorials.stanMetrics.nameTitle', { + defaultMessage: 'STAN metrics', + }), + category: TUTORIAL_CATEGORY.METRICS, + shortDescription: i18n.translate('kbn.server.tutorials.stanMetrics.shortDescription', { + defaultMessage: 'Fetch monitoring metrics from the STAN server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.stanMetrics.longDescription', { + defaultMessage: + 'The `stan` Metricbeat module fetches monitoring metrics from STAN. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-stan.html', + }, + }), + euiIconType: '/plugins/kibana/home/tutorial_resources/logos/stan.svg', + artifacts: { + dashboards: [], + exportedFields: { + documentationUrl: '{config.docs.beats.metricbeat}/exported-fields-stan.html', + }, + }, + completionTimeMinutes: 10, + // previewImagePath: '/plugins/kibana/home/tutorial_resources/stan_metrics/screenshot.png', + onPrem: onPremInstructions(moduleName, null, null, null, context), + elasticCloud: cloudInstructions(moduleName), + onPremElasticCloud: onPremCloudInstructions(moduleName), + }; +} From 753eb53448b586de0316d7610f3b38553359469d Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Fri, 10 Jan 2020 10:56:28 +0100 Subject: [PATCH 63/63] [SearchProfiler] Remove sources of recursion over potentially deeply nested objects (#54015) * Added max tree depth guard Removed recursive normalizeTimes functions (one fewer iteration through the entire data structure) Optimizied appliation of tree mutations by taking `if` out of tight loop Cleaned up types * Tidy up data being passed into store (and through immer) * Fix max tree depth logic * Remove immer from non-test code. Co-authored-by: Elastic Machine --- .../highlight_details_flyout.test.tsx | 13 +- .../highlight_details_flyout.tsx | 12 +- .../highlight_details_table.tsx | 2 +- .../components/percentage_badge.tsx | 2 +- .../__tests__/fixtures/normalize_times.ts | 1185 +++++++++-------- .../__tests__/fixtures/search_response.ts | 108 ++ .../profile_tree/__tests__/init_data.test.ts | 13 + .../__tests__/unsafe_utils.test.ts | 7 +- .../components/profile_tree/constants.ts | 8 + .../components/profile_tree/init_data.ts | 110 +- .../shard_details/shard_details.tsx | 2 +- .../shard_details/shard_details_tree_node.tsx | 5 +- .../components/profile_tree/unsafe_utils.ts | 86 +- .../application/store/reducer.test.ts | 60 + .../np_ready/application/store/reducer.ts | 66 +- .../np_ready/application/store/store.ts | 13 +- .../public/np_ready/application/types.ts | 2 +- 17 files changed, 947 insertions(+), 747 deletions(-) create mode 100644 x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/constants.ts create mode 100644 x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.test.ts diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.test.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.test.tsx index cf8052cbf3580..30a4c1dce1dff 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.test.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.test.tsx @@ -11,16 +11,8 @@ describe('Highlight Details Flyout', () => { it('renders', async () => { const props: Props = { onClose: () => {}, - shard: { - aggregations: [], - id: ['test', 'test', 'test'], - searches: [], - color: '#fff', - time: 123, - relative: 100, - }, + shardName: '[test][test]', operation: { - parent: null, breakdown: [ { color: 'test', @@ -48,8 +40,7 @@ describe('Highlight Details Flyout', () => { query_type: 'test', selfTime: 100, time: 100, - children: [], - timePercentage: 100, + timePercentage: '100', hasChildren: false, visible: true, absoluteColor: '123', diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.tsx index 6ba39d15b8341..40c17df95456a 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.tsx @@ -17,11 +17,11 @@ import { import { msToPretty } from '../../utils'; import { HighlightDetailsTable } from './highlight_details_table'; -import { Operation, Shard } from '../../types'; +import { Operation } from '../../types'; export interface Props { - operation: Operation; - shard: Shard; + operation: Omit; + shardName: string; indexName: string; onClose: () => void; } @@ -39,14 +39,12 @@ const FlyoutEntry = ({ ); -export const HighlightDetailsFlyout = ({ indexName, operation, shard, onClose }: Props) => { +export const HighlightDetailsFlyout = ({ indexName, operation, shardName, onClose }: Props) => { return ( onClose()}> {indexName} - - [{/* shard id */ shard.id[0]}][{/* shard number */ shard.id[2]}] - + {shardName} diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_table.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_table.tsx index 4bfa7365de1ef..f41088b7c9b78 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_table.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_table.tsx @@ -36,7 +36,7 @@ export const HighlightDetailsTable = ({ breakdown }: Props) => { { name: 'Percentage', render: (item: BreakdownItem) => ( - + ), }, ]; diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/percentage_badge.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/percentage_badge.tsx index 4b53b2e3c18c5..e39e37e8656db 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/percentage_badge.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/percentage_badge.tsx @@ -9,7 +9,7 @@ import { EuiBadge } from '@elastic/eui'; import classNames from 'classnames'; interface Props { - timePercentage: number; + timePercentage: string; label: string; valueType?: 'percent' | 'time'; } diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/normalize_times.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/normalize_times.ts index 714b856b54c68..f26de49138f12 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/normalize_times.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/normalize_times.ts @@ -1,802 +1,809 @@ export const inputTimes = [ { - type:"BooleanQuery", - description:"hour:1 hour:2 #MatchNoDocsQuery[\"User requested \"match_none\" query.\"]", - time:0.447365, - breakdown:[ + type: 'BooleanQuery', + description: 'hour:1 hour:2 #MatchNoDocsQuery["User requested "match_none" query."]', + time: 0.447365, + breakdown: [ { - key:"create_weight", - time:401690, - relative:"89.8", - color:"#feb6b6", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 401690, + relative: '89.8', + color: '#feb6b6', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:45672, - relative:"10.2", - color:"#f6eeee", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 45672, + relative: '10.2', + color: '#f6eeee', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - "children":[ + children: [ { - type:"TermQuery", - description:"hour:1", - time:0.192502, - breakdown:[ + type: 'TermQuery', + description: 'hour:1', + time: 0.192502, + breakdown: [ { - key:"create_weight", - time:190989, - relative:"99.2", - color:"#ffb0b0", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 190989, + relative: '99.2', + color: '#ffb0b0', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:1510, - relative:"0.8", - color:"#f5f4f4", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 1510, + relative: '0.8', + color: '#f5f4f4', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"3339dca6-c34a-49f3-a534-27e46f238bcd", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.192502 + id: '3339dca6-c34a-49f3-a534-27e46f238bcd', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.192502, }, { - type:"TermQuery", - description:"hour:2", - time:0.162608, - breakdown:[ + type: 'TermQuery', + description: 'hour:2', + time: 0.162608, + breakdown: [ { - key:"create_weight", - time:162016, - relative:"99.6", - color:"#ffafaf", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 162016, + relative: '99.6', + color: '#ffafaf', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:589, - relative:"0.4", - color:"#f5f5f5", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 589, + relative: '0.4', + color: '#f5f5f5', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"9b75ecdd-a1da-45eb-8d13-5bc5f472dba3", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.162608 + id: '9b75ecdd-a1da-45eb-8d13-5bc5f472dba3', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.162608, }, { - type:"MatchNoDocsQuery", - description:"MatchNoDocsQuery[\"User requested \"match_none\" query.\"]", - time:0.03517, - breakdown:[ + type: 'MatchNoDocsQuery', + description: 'MatchNoDocsQuery["User requested "match_none" query."]', + time: 0.03517, + breakdown: [ { - key:"build_scorer", - time:32522, - relative:"92.5", - color:"#feb4b4", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 32522, + relative: '92.5', + color: '#feb4b4', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"create_weight", - time:2645, - relative:"7.5", - color:"#f6f0f0", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 2645, + relative: '7.5', + color: '#f6f0f0', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.03517 - } + id: 'ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.03517, + }, ], - id:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - "3339dca6-c34a-49f3-a534-27e46f238bcd", - "9b75ecdd-a1da-45eb-8d13-5bc5f472dba3", - "ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb" + id: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [ + '3339dca6-c34a-49f3-a534-27e46f238bcd', + '9b75ecdd-a1da-45eb-8d13-5bc5f472dba3', + 'ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb', ], - hasChildren:true, - selfTime:0.057085 - } + hasChildren: true, + selfTime: 0.057085, + }, ]; - export const normalizedTimes = [ { - type:"BooleanQuery", - description:"hour:1 hour:2 #MatchNoDocsQuery[\"User requested \"match_none\" query.\"]", - time:0.447365, - breakdown:[ + type: 'BooleanQuery', + description: 'hour:1 hour:2 #MatchNoDocsQuery["User requested "match_none" query."]', + time: 0.447365, + breakdown: [ { - key:"create_weight", - time:401690, - relative:"89.8", - color:"#feb6b6", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 401690, + relative: '89.8', + color: '#feb6b6', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:45672, - relative:"10.2", - color:"#f6eeee", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 45672, + relative: '10.2', + color: '#f6eeee', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - "children":[ + children: [ { - type:"TermQuery", - description:"hour:1", - time:0.192502, - breakdown:[ + type: 'TermQuery', + description: 'hour:1', + time: 0.192502, + breakdown: [ { - key:"create_weight", - time:190989, - relative:"99.2", - color:"#ffb0b0", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 190989, + relative: '99.2', + color: '#ffb0b0', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:1510, - relative:"0.8", - color:"#f5f4f4", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 1510, + relative: '0.8', + color: '#f5f4f4', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"3339dca6-c34a-49f3-a534-27e46f238bcd", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.192502, - timePercentage:"43.03", - absoluteColor:"#f9d7d7", - depth:1 + id: '3339dca6-c34a-49f3-a534-27e46f238bcd', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.192502, + timePercentage: '43.03', + absoluteColor: '#f9d7d7', }, { - type:"TermQuery", - description:"hour:2", - time:0.162608, - breakdown:[ + type: 'TermQuery', + description: 'hour:2', + time: 0.162608, + breakdown: [ { - key:"create_weight", - time:162016, - relative:"99.6", - color:"#ffafaf", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 162016, + relative: '99.6', + color: '#ffafaf', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:589, - relative:"0.4", - color:"#f5f5f5", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 589, + relative: '0.4', + color: '#f5f5f5', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"9b75ecdd-a1da-45eb-8d13-5bc5f472dba3", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.162608, - timePercentage:"36.35", - absoluteColor:"#f9dcdc", - depth:1 + id: '9b75ecdd-a1da-45eb-8d13-5bc5f472dba3', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.162608, + timePercentage: '36.35', + absoluteColor: '#f9dcdc', }, { - type:"MatchNoDocsQuery", - description:"MatchNoDocsQuery[\"User requested \"match_none\" query.\"]", - time:0.03517, - breakdown:[ + type: 'MatchNoDocsQuery', + description: 'MatchNoDocsQuery["User requested "match_none" query."]', + time: 0.03517, + breakdown: [ { - key:"build_scorer", - time:32522, - relative:"92.5", - color:"#feb4b4", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 32522, + relative: '92.5', + color: '#feb4b4', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"create_weight", - time:2645, - relative:"7.5", - color:"#f6f0f0", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 2645, + relative: '7.5', + color: '#f6f0f0', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.03517, - timePercentage:"7.86", - absoluteColor:"#f6efef", - depth:1 - } + id: 'ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.03517, + timePercentage: '7.86', + absoluteColor: '#f6efef', + }, ], - id:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - "3339dca6-c34a-49f3-a534-27e46f238bcd", - "9b75ecdd-a1da-45eb-8d13-5bc5f472dba3", - "ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb" + id: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [ + '3339dca6-c34a-49f3-a534-27e46f238bcd', + '9b75ecdd-a1da-45eb-8d13-5bc5f472dba3', + 'ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb', ], - hasChildren:true, - selfTime:0.057085, - timePercentage:"100.00", - absoluteColor:"#ffafaf", - depth:0 - } + hasChildren: true, + selfTime: 0.057085, + timePercentage: '100.00', + absoluteColor: '#ffafaf', + }, ]; diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/search_response.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/search_response.ts index b4483cc0fc58e..335c6addd40d7 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/search_response.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/search_response.ts @@ -182,6 +182,114 @@ export const searchResponse: any = [ create_weight_count: 1, build_scorer: 17681, }, + children: [ + { + type: 'DocValuesFieldExistsQuery', + description: 'DocValuesFieldExistsQuery [field=_primary_term]', + time_in_nanos: 19795, + breakdown: { + set_min_competitive_score_count: 0, + match_count: 0, + shallow_advance_count: 0, + set_min_competitive_score: 0, + next_doc: 600, + match: 0, + next_doc_count: 5, + score_count: 0, + compute_max_score_count: 0, + compute_max_score: 0, + advance: 378, + advance_count: 3, + score: 0, + build_scorer_count: 6, + create_weight: 1121, + shallow_advance: 0, + create_weight_count: 1, + build_scorer: 17681, + }, + children: [ + { + type: 'DocValuesFieldExistsQuery', + description: 'DocValuesFieldExistsQuery [field=_primary_term]', + time_in_nanos: 19795, + breakdown: { + set_min_competitive_score_count: 0, + match_count: 0, + shallow_advance_count: 0, + set_min_competitive_score: 0, + next_doc: 600, + match: 0, + next_doc_count: 5, + score_count: 0, + compute_max_score_count: 0, + compute_max_score: 0, + advance: 378, + advance_count: 3, + score: 0, + build_scorer_count: 6, + create_weight: 1121, + shallow_advance: 0, + create_weight_count: 1, + build_scorer: 17681, + }, + children: [ + { + type: 'DocValuesFieldExistsQuery', + description: 'DocValuesFieldExistsQuery [field=_primary_term]', + time_in_nanos: 19795, + breakdown: { + set_min_competitive_score_count: 0, + match_count: 0, + shallow_advance_count: 0, + set_min_competitive_score: 0, + next_doc: 600, + match: 0, + next_doc_count: 5, + score_count: 0, + compute_max_score_count: 0, + compute_max_score: 0, + advance: 378, + advance_count: 3, + score: 0, + build_scorer_count: 6, + create_weight: 1121, + shallow_advance: 0, + create_weight_count: 1, + build_scorer: 17681, + }, + children: [ + { + type: 'DocValuesFieldExistsQuery', + description: 'DocValuesFieldExistsQuery [field=_primary_term]', + time_in_nanos: 19795, + breakdown: { + set_min_competitive_score_count: 0, + match_count: 0, + shallow_advance_count: 0, + set_min_competitive_score: 0, + next_doc: 600, + match: 0, + next_doc_count: 5, + score_count: 0, + compute_max_score_count: 0, + compute_max_score: 0, + advance: 378, + advance_count: 3, + score: 0, + build_scorer_count: 6, + create_weight: 1121, + shallow_advance: 0, + create_weight_count: 1, + build_scorer: 17681, + }, + }, + ], + }, + ], + }, + ], + }, + ], }, ], }, diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/init_data.test.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/init_data.test.ts index 6cd19947a26bc..78247f0ccf31b 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/init_data.test.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/init_data.test.ts @@ -5,6 +5,9 @@ */ import { ShardSerialized } from '../../../types'; +jest.mock('../constants', () => ({ + MAX_TREE_DEPTH: 3, +})); import { initDataFor } from '../init_data'; import { searchResponse } from './fixtures/search_response'; @@ -15,6 +18,16 @@ describe('ProfileTree init data', () => { const input: ShardSerialized[] = searchResponse as any; const actual = initDataFor('searches')(input); + /* prettier-ignore */ + expect( + actual[1].shards[0].searches![0] + .treeRoot! // level 0 + .children[0] // level 1 + .children[0] // level 2 + .children[0] // level 3 -- Max level! + .children, // level 4 (nothing here!) + ).toEqual([]); + expect(actual[0].name).toEqual(processedResponseWithFirstShard[0].name); const expectedFirstShard = processedResponseWithFirstShard[0].shards[0]; expect(actual[0].shards[0]).toEqual(expectedFirstShard); diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/unsafe_utils.test.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/unsafe_utils.test.ts index 17c7051f09769..ca423310e8375 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/unsafe_utils.test.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/unsafe_utils.test.ts @@ -17,13 +17,16 @@ describe('normalizeBreakdown', function() { }); }); -describe('normalizeTimes', function() { +describe('normalizeTime', function() { it('returns correct normalization', function() { const totalTime = 0.447365; // Deep clone the object to preserve the original const input = JSON.parse(JSON.stringify(inputTimes)); - util.normalizeTimes(input, totalTime, 0); + + // Simulate recursive application to the tree. + input.forEach((i: any) => util.normalizeTime(i, totalTime)); + input[0].children.forEach((i: any) => util.normalizeTime(i, totalTime)); // Modifies in place, so inputTimes will change expect(input).to.eql(normalizedTimes); diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/constants.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/constants.ts new file mode 100644 index 0000000000000..b5122f93f1981 --- /dev/null +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// Prevent recursive data structures from blowing up the JS call stack. +export const MAX_TREE_DEPTH = 40; diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/init_data.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/init_data.ts index 642b2b741abf0..af24a8936c915 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/init_data.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/init_data.ts @@ -4,18 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { produce } from 'immer'; +import cloneDeep from 'lodash.clonedeep'; import { flow } from 'fp-ts/lib/function'; import { Targets, Shard, ShardSerialized } from '../../types'; -import { calcTimes, normalizeTimes, initTree, normalizeIndices, sortIndices } from './unsafe_utils'; +import { calcTimes, initTree, normalizeIndices, sortIndices } from './unsafe_utils'; import { IndexMap } from './types'; /** * Functions prefixed with "mutate" change values by reference. Be careful when using these! - * - * It's recommended to us immer's `produce` functions to ensure immutability. */ - export function mutateAggsTimesTree(shard: Shard) { if (shard.aggregations == null) { shard.time = 0; @@ -26,8 +23,7 @@ export function mutateAggsTimesTree(shard: Shard) { shardTime += totalTime; } for (const agg of shard.aggregations!) { - normalizeTimes([agg], shardTime, 0); - initTree([agg], 0); + initTree([agg], shardTime); } shard.time = shardTime; } @@ -43,66 +39,76 @@ export function mutateSearchTimesTree(shard: Shard) { shard.rewrite_time += search.rewrite_time!; const totalTime = calcTimes(search.query!); shardTime += totalTime; - normalizeTimes(search.query!, totalTime, 0); - initTree(search.query!, 0); + initTree(search.query!, totalTime); search.treeRoot = search.query![0]; + // Remove this object. search.query = null as any; } shard.time = shardTime; } const initShards = (data: ShardSerialized[]) => - produce(data, draft => { - return draft.map(s => { - const idMatch = s.id.match(/\[([^\]\[]*?)\]/g) || []; - const ids = idMatch.map(id => { - return id.replace('[', '').replace(']', ''); - }); - return { - ...s, - id: ids, - time: 0, - color: '', - relative: 0, - }; + data.map(s => { + const idMatch = s.id.match(/\[([^\]\[]*?)\]/g) || []; + const ids = idMatch.map(id => { + return id.replace('[', '').replace(']', ''); }); + return { + ...s, + id: ids, + time: 0, + color: '', + relative: 0, + }; }); -export const calculateShardValues = (target: Targets) => (data: Shard[]) => - produce(data, draft => { - for (const shard of draft) { - if (target === 'searches') { - mutateSearchTimesTree(shard); - } else if (target === 'aggregations') { - mutateAggsTimesTree(shard); - } +export const calculateShardValues = (target: Targets) => (data: Shard[]) => { + const mutateTimesTree = + target === 'searches' + ? mutateSearchTimesTree + : target === 'aggregations' + ? mutateAggsTimesTree + : null; + + if (mutateTimesTree) { + for (const shard of data) { + mutateTimesTree(shard); } - }); + } -export const initIndices = (data: Shard[]) => - produce(data, doNotChange => { - const indices: IndexMap = {}; + return data; +}; - for (const shard of doNotChange) { - if (!indices[shard.id[1]]) { - indices[shard.id[1]] = { - shards: [], - time: 0, - name: shard.id[1], - visible: false, - }; - } - indices[shard.id[1]].shards.push(shard); - indices[shard.id[1]].time += shard.time; +export const initIndices = (data: Shard[]) => { + const indices: IndexMap = {}; + + for (const shard of data) { + if (!indices[shard.id[1]]) { + indices[shard.id[1]] = { + shards: [], + time: 0, + name: shard.id[1], + visible: false, + }; } + indices[shard.id[1]].shards.push(shard); + indices[shard.id[1]].time += shard.time; + } - return indices; - }); + return indices; +}; -export const normalize = (target: Targets) => (data: IndexMap) => - produce(data, draft => { - normalizeIndices(draft, target); - }); +export const normalize = (target: Targets) => (data: IndexMap) => { + normalizeIndices(data, target); + return data; +}; export const initDataFor = (target: Targets) => - flow(initShards, calculateShardValues(target), initIndices, normalize(target), sortIndices); + flow( + cloneDeep, + initShards, + calculateShardValues(target), + initIndices, + normalize(target), + sortIndices + ); diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details.tsx index eca2d1994f8c5..5ca8ad4ecd979 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details.tsx @@ -38,7 +38,7 @@ export const ShardDetails = ({ index, shard, operations }: Props) => { diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details_tree_node.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details_tree_node.tsx index 75da10f8aca2e..1d8f915d3d47d 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details_tree_node.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details_tree_node.tsx @@ -28,8 +28,7 @@ const limitString = (string: string, limit: number) => `${string.slice(0, limit)}${string.length > limit ? '...' : ''}`; /** - * This is a component that recursively iterates over data to render out a tree like - * structure in a flatly. + * This component recursively renders a tree */ export const ShardDetailsTreeNode = ({ operation, index, shard }: Props) => { const [childrenVisible, setChildrenVisible] = useState(hasVisibleChild(operation)); @@ -106,7 +105,7 @@ export const ShardDetailsTreeNode = ({ operation, index, shard }: Props) => {
{childrenVisible && operation.hasChildren && - operation.children.flatMap((childOp, idx) => ( + operation.children.map((childOp, idx) => ( ))} diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/unsafe_utils.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/unsafe_utils.ts index fc21c5da37764..2201ad42070b7 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/unsafe_utils.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/unsafe_utils.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { produce } from 'immer'; import { i18n } from '@kbn/i18n'; import tinycolor from 'tinycolor2'; import _ from 'lodash'; import { BreakdownItem, Index, Operation, Shard, Targets } from '../../types'; import { IndexMap } from './types'; +import { MAX_TREE_DEPTH } from './constants'; export const comparator = (v1: number, v2: number) => { if (v1 < v2) { @@ -53,16 +53,16 @@ function getToolTip(key: string) { } } -export function timeInMilliseconds(data: any) { +export function timeInMilliseconds(data: any): number { if (data.time_in_nanos) { return data.time_in_nanos / 1000000; } if (typeof data.time === 'string') { - return data.time.replace('ms', ''); + return Number(data.time.replace('ms', '')); } - return data.time; + return Number(data.time); } export function calcTimes(data: any[], parentId?: string) { @@ -117,21 +117,6 @@ export function normalizeBreakdown(breakdown: Record) { }); } -export function normalizeTimes(data: any[], totalTime: number, depth: number) { - // Second pass to normalize - for (const child of data) { - child.timePercentage = ((timeInMilliseconds(child) / totalTime) * 100).toFixed(2); - child.absoluteColor = tinycolor.mix('#F5F5F5', '#FFAFAF', child.timePercentage).toHexString(); - child.depth = depth; - - if (child.children != null && child.children.length !== 0) { - normalizeTimes(child.children, totalTime, depth + 1); - } - } - - data.sort((a, b) => comparator(timeInMilliseconds(a), timeInMilliseconds(b))); -} - export function normalizeIndices(indices: IndexMap, target: Targets) { // Sort the shards per-index let sortQueryComponents; @@ -167,7 +152,26 @@ export function normalizeIndices(indices: IndexMap, target: Targets) { } } -export function initTree(data: Operation[], depth = 0, parent: Operation | null = null) { +export function normalizeTime(operation: Operation, totalTime: number) { + operation.timePercentage = ((timeInMilliseconds(operation) / totalTime) * 100).toFixed(2); + operation.absoluteColor = tinycolor + .mix('#F5F5F5', '#FFAFAF', +operation.timePercentage) + .toHexString(); +} + +export function initTree( + data: Operation[], + totalTime: number, + depth = 0, + parent: Operation | null = null +) { + if (MAX_TREE_DEPTH + 1 === depth) { + if (parent) { + parent!.hasChildren = false; + parent!.children = []; + } + return; + } for (const child of data) { // For bwc of older profile responses if (!child.description) { @@ -178,44 +182,28 @@ export function initTree(data: Operation[], depth = 0, parent: Operation | nu child.query_type = null; } - // Use named function for tests. + normalizeTime(child, totalTime); child.parent = parent; child.time = timeInMilliseconds(child); child.lucene = child.description; child.query_type = child.type!.split('.').pop()!; - child.visible = child.timePercentage > 20; + child.visible = +child.timePercentage > 20; child.depth = depth; if (child.children != null && child.children.length !== 0) { - initTree(child.children, depth + 1, child); + initTree(child.children, totalTime, depth + 1, child); } } -} - -export function closeNode(node: Operation) { - const closeDraft = (draft: Operation) => { - draft.visible = false; - if (draft.children == null || draft.children.length === 0) { - return; - } - - for (const child of draft.children) { - closeDraft(child); - } - }; - return produce(node, draft => { - closeDraft(draft); - }); + data.sort((a, b) => comparator(timeInMilliseconds(a), timeInMilliseconds(b))); } -export const sortIndices = (data: IndexMap) => - produce(data, doNotChange => { - const sortedIndices: Index[] = []; - for (const index of Object.values(doNotChange)) { - sortedIndices.push(index); - } - // And now sort the indices themselves - sortedIndices.sort((a, b) => comparator(a.time, b.time)); - return sortedIndices; - }); +export const sortIndices = (data: IndexMap) => { + const sortedIndices: Index[] = []; + for (const index of Object.values(data)) { + sortedIndices.push(index); + } + // And now sort the indices themselves + sortedIndices.sort((a, b) => comparator(a.time, b.time)); + return sortedIndices; +}; diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.test.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.test.ts new file mode 100644 index 0000000000000..14424a1c88529 --- /dev/null +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { reducer } from './reducer'; +import { initialState, State } from './store'; + +describe('Searchprofiler store reducer', () => { + let state: State; + + beforeEach(() => { + state = initialState; + }); + + it('profiles as expected', () => { + const nextState = reducer(state, { type: 'setProfiling', value: true }); + + expect(nextState).toEqual({ + ...state, + pristine: false, + profiling: true, + } as State); + + const finalState = reducer(nextState, { type: 'setProfiling', value: false }); + + expect(finalState).toEqual({ + ...nextState, + pristine: false, + profiling: false, + } as State); + }); + + it('highlights as expected', () => { + const op = { children: null } as any; + const shard = { id: ['a', 'b', 'c'] } as any; + const nextState = reducer(state, { + type: 'setHighlightDetails', + value: { operation: op, indexName: 'test', shard }, + }); + + expect(nextState).toEqual({ + ...state, + highlightDetails: { + operation: { + /* .children no longer defined */ + }, + shardName: '[a][c]', + indexName: 'test', + }, + }); + + const finalState = reducer(state, { + type: 'setHighlightDetails', + value: null, + }); + + expect(finalState).toEqual({ ...state, highlightDetails: null }); + }); +}); diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.ts index 615511786afd1..394110ac49524 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { produce } from 'immer'; import { Reducer } from 'react'; import { State } from './store'; @@ -17,36 +16,49 @@ export type Action = | { type: 'setActiveTab'; value: Targets | null } | { type: 'setCurrentResponse'; value: ShardSerialized[] | null }; -export const reducer: Reducer = (state, action) => - produce(state, draft => { - if (action.type === 'setProfiling') { - draft.pristine = false; - draft.profiling = action.value; - if (draft.profiling) { - draft.currentResponse = null; - draft.highlightDetails = null; - } - return; - } +export const reducer: Reducer = (state, action) => { + const nextState = { ...state }; - if (action.type === 'setHighlightDetails') { - draft.highlightDetails = action.value; - return; + if (action.type === 'setProfiling') { + nextState.pristine = false; + nextState.profiling = action.value; + if (nextState.profiling) { + nextState.currentResponse = null; + nextState.highlightDetails = null; } + return nextState; + } - if (action.type === 'setActiveTab') { - draft.activeTab = action.value; - return; + if (action.type === 'setHighlightDetails') { + if (action.value) { + const value = action.value; + // Exclude children to avoid unnecessary work copying a recursive structure. + const { children, parent, ...restOfOperation } = value.operation; + nextState.highlightDetails = { + indexName: value.indexName, + operation: Object.freeze(restOfOperation), + // prettier-ignore + shardName: `[${/* shard id */value.shard.id[0]}][${/* shard number */value.shard.id[2] }]` + }; + } else { + nextState.highlightDetails = null; } + return nextState; + } + + if (action.type === 'setActiveTab') { + nextState.activeTab = action.value; + return nextState; + } - if (action.type === 'setCurrentResponse') { - draft.currentResponse = action.value; - if (draft.currentResponse) { - // Default to the searches tab - draft.activeTab = 'searches'; - } - return; + if (action.type === 'setCurrentResponse') { + nextState.currentResponse = action.value; + if (nextState.currentResponse) { + // Default to the searches tab + nextState.activeTab = 'searches'; } + return nextState; + } - throw new Error(`Unknown action: ${action}`); - }); + throw new Error(`Unknown action: ${action}`); +}; diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/store.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/store.ts index 7008854a16285..45194ff917c93 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/store.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/store.ts @@ -5,13 +5,20 @@ */ import { useReducer } from 'react'; import { reducer } from './reducer'; -import { ShardSerialized, Targets } from '../types'; -import { OnHighlightChangeArgs } from '../components/profile_tree'; +import { Operation, ShardSerialized, Targets } from '../types'; + +export type OperationNoChildParent = Omit; + +interface HighlightDetails { + indexName: string; + operation: OperationNoChildParent; + shardName: string; +} export interface State { profiling: boolean; pristine: boolean; - highlightDetails: OnHighlightChangeArgs | null; + highlightDetails: HighlightDetails | null; activeTab: Targets | null; currentResponse: ShardSerialized[] | null; } diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/types.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/types.ts index 2b4dc01c45d6f..9866f8d5b1ccb 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/types.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/types.ts @@ -42,7 +42,7 @@ export interface Operation { hasChildren: boolean; visible: boolean; selfTime: number; - timePercentage: number; + timePercentage: string; absoluteColor: string; time: number;