diff --git a/.eslintrc.js b/.eslintrc.js index ce4aea3c02309..56950a70970d4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -149,6 +149,7 @@ module.exports = { 'src/core/server/**/*', '!src/core/server/index.ts', '!src/core/server/mocks.ts', + '!src/core/server/types.ts', '!src/core/server/*.test.mocks.ts', 'src/plugins/**/public/**/*', diff --git a/docs/development/core/public/kibana-plugin-public.environmentmode.dev.md b/docs/development/core/public/kibana-plugin-public.environmentmode.dev.md new file mode 100644 index 0000000000000..b82e851da2b66 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.environmentmode.dev.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [EnvironmentMode](./kibana-plugin-public.environmentmode.md) > [dev](./kibana-plugin-public.environmentmode.dev.md) + +## EnvironmentMode.dev property + +Signature: + +```typescript +dev: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-public.environmentmode.md b/docs/development/core/public/kibana-plugin-public.environmentmode.md new file mode 100644 index 0000000000000..14ab1316f5269 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.environmentmode.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [EnvironmentMode](./kibana-plugin-public.environmentmode.md) + +## EnvironmentMode interface + + +Signature: + +```typescript +export interface EnvironmentMode +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [dev](./kibana-plugin-public.environmentmode.dev.md) | boolean | | +| [name](./kibana-plugin-public.environmentmode.name.md) | 'development' | 'production' | | +| [prod](./kibana-plugin-public.environmentmode.prod.md) | boolean | | + diff --git a/docs/development/core/public/kibana-plugin-public.environmentmode.name.md b/docs/development/core/public/kibana-plugin-public.environmentmode.name.md new file mode 100644 index 0000000000000..5983fea856750 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.environmentmode.name.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [EnvironmentMode](./kibana-plugin-public.environmentmode.md) > [name](./kibana-plugin-public.environmentmode.name.md) + +## EnvironmentMode.name property + +Signature: + +```typescript +name: 'development' | 'production'; +``` diff --git a/docs/development/core/public/kibana-plugin-public.environmentmode.prod.md b/docs/development/core/public/kibana-plugin-public.environmentmode.prod.md new file mode 100644 index 0000000000000..4b46e8b9cc9f9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.environmentmode.prod.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [EnvironmentMode](./kibana-plugin-public.environmentmode.md) > [prod](./kibana-plugin-public.environmentmode.prod.md) + +## EnvironmentMode.prod property + +Signature: + +```typescript +prod: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index a7a495fb23240..152cdb8ffe433 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -44,6 +44,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | | [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the Plugin start lifecycle | | [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | +| [EnvironmentMode](./kibana-plugin-public.environmentmode.md) | | | [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | | [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | | [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | @@ -69,6 +70,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | | | [OverlayRef](./kibana-plugin-public.overlayref.md) | Returned by [OverlayStart](./kibana-plugin-public.overlaystart.md) methods for closing a mounted overlay. | | [OverlayStart](./kibana-plugin-public.overlaystart.md) | | +| [PackageInfo](./kibana-plugin-public.packageinfo.md) | | | [Plugin](./kibana-plugin-public.plugin.md) | The interface that should be returned by a PluginInitializer. | | [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | | [SavedObject](./kibana-plugin-public.savedobject.md) | | diff --git a/docs/development/core/public/kibana-plugin-public.packageinfo.branch.md b/docs/development/core/public/kibana-plugin-public.packageinfo.branch.md new file mode 100644 index 0000000000000..774a290969938 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.packageinfo.branch.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [PackageInfo](./kibana-plugin-public.packageinfo.md) > [branch](./kibana-plugin-public.packageinfo.branch.md) + +## PackageInfo.branch property + +Signature: + +```typescript +branch: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.packageinfo.buildnum.md b/docs/development/core/public/kibana-plugin-public.packageinfo.buildnum.md new file mode 100644 index 0000000000000..0c1003f8d8aff --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.packageinfo.buildnum.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [PackageInfo](./kibana-plugin-public.packageinfo.md) > [buildNum](./kibana-plugin-public.packageinfo.buildnum.md) + +## PackageInfo.buildNum property + +Signature: + +```typescript +buildNum: number; +``` diff --git a/docs/development/core/public/kibana-plugin-public.packageinfo.buildsha.md b/docs/development/core/public/kibana-plugin-public.packageinfo.buildsha.md new file mode 100644 index 0000000000000..98a7916c142e9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.packageinfo.buildsha.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [PackageInfo](./kibana-plugin-public.packageinfo.md) > [buildSha](./kibana-plugin-public.packageinfo.buildsha.md) + +## PackageInfo.buildSha property + +Signature: + +```typescript +buildSha: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.packageinfo.dist.md b/docs/development/core/public/kibana-plugin-public.packageinfo.dist.md new file mode 100644 index 0000000000000..c70299bbe6fcc --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.packageinfo.dist.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [PackageInfo](./kibana-plugin-public.packageinfo.md) > [dist](./kibana-plugin-public.packageinfo.dist.md) + +## PackageInfo.dist property + +Signature: + +```typescript +dist: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-public.packageinfo.md b/docs/development/core/public/kibana-plugin-public.packageinfo.md new file mode 100644 index 0000000000000..1dbd8cb85c56b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.packageinfo.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [PackageInfo](./kibana-plugin-public.packageinfo.md) + +## PackageInfo interface + + +Signature: + +```typescript +export interface PackageInfo +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [branch](./kibana-plugin-public.packageinfo.branch.md) | string | | +| [buildNum](./kibana-plugin-public.packageinfo.buildnum.md) | number | | +| [buildSha](./kibana-plugin-public.packageinfo.buildsha.md) | string | | +| [dist](./kibana-plugin-public.packageinfo.dist.md) | boolean | | +| [version](./kibana-plugin-public.packageinfo.version.md) | string | | + diff --git a/docs/development/core/public/kibana-plugin-public.packageinfo.version.md b/docs/development/core/public/kibana-plugin-public.packageinfo.version.md new file mode 100644 index 0000000000000..26def753e424a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.packageinfo.version.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [PackageInfo](./kibana-plugin-public.packageinfo.md) > [version](./kibana-plugin-public.packageinfo.version.md) + +## PackageInfo.version property + +Signature: + +```typescript +version: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.env.md b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.env.md new file mode 100644 index 0000000000000..92f36ab64a1d6 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.env.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) > [env](./kibana-plugin-public.plugininitializercontext.env.md) + +## PluginInitializerContext.env property + +Signature: + +```typescript +readonly env: { + mode: Readonly; + packageInfo: Readonly; + }; +``` diff --git a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md index 3ad220349c45c..87c39a502040d 100644 --- a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md +++ b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md @@ -16,5 +16,6 @@ export interface PluginInitializerContext | Property | Type | Description | | --- | --- | --- | +| [env](./kibana-plugin-public.plugininitializercontext.env.md) | {
mode: Readonly<EnvironmentMode>;
packageInfo: Readonly<PackageInfo>;
} | | | [opaqueId](./kibana-plugin-public.plugininitializercontext.opaqueid.md) | PluginOpaqueId | A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. | diff --git a/docs/development/core/public/kibana-plugin-public.toastsapi.get_.md b/docs/development/core/public/kibana-plugin-public.toastsapi.get_.md index ec02654fa3c82..48e4fdc7a2ec0 100644 --- a/docs/development/core/public/kibana-plugin-public.toastsapi.get_.md +++ b/docs/development/core/public/kibana-plugin-public.toastsapi.get_.md @@ -4,7 +4,7 @@ ## ToastsApi.get$() method -Current array of toast messages to show to user. +Observable of the toast messages to show to the user. Signature: diff --git a/docs/development/core/public/kibana-plugin-public.toastsapi.md b/docs/development/core/public/kibana-plugin-public.toastsapi.md index 4a53a59d9b173..e47f6d5c8ac59 100644 --- a/docs/development/core/public/kibana-plugin-public.toastsapi.md +++ b/docs/development/core/public/kibana-plugin-public.toastsapi.md @@ -27,6 +27,6 @@ export declare class ToastsApi implements IToasts | [addError(error, options)](./kibana-plugin-public.toastsapi.adderror.md) | | Adds a new toast that displays an exception message with a button to open the full stacktrace in a modal. | | [addSuccess(toastOrTitle)](./kibana-plugin-public.toastsapi.addsuccess.md) | | Adds a new toast pre-configured with the success color and check icon. | | [addWarning(toastOrTitle)](./kibana-plugin-public.toastsapi.addwarning.md) | | Adds a new toast pre-configured with the warning color and help icon. | -| [get$()](./kibana-plugin-public.toastsapi.get_.md) | | Current array of toast messages to show to user. | +| [get$()](./kibana-plugin-public.toastsapi.get_.md) | | Observable of the toast messages to show to the user. | | [remove(toast)](./kibana-plugin-public.toastsapi.remove.md) | | Removes a toast from the current array of toasts if present. | diff --git a/docs/development/core/server/kibana-plugin-server.environmentmode.dev.md b/docs/development/core/server/kibana-plugin-server.environmentmode.dev.md new file mode 100644 index 0000000000000..d60fcc58d1b60 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.environmentmode.dev.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [EnvironmentMode](./kibana-plugin-server.environmentmode.md) > [dev](./kibana-plugin-server.environmentmode.dev.md) + +## EnvironmentMode.dev property + +Signature: + +```typescript +dev: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.environmentmode.md b/docs/development/core/server/kibana-plugin-server.environmentmode.md new file mode 100644 index 0000000000000..b325f74a0a44f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.environmentmode.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [EnvironmentMode](./kibana-plugin-server.environmentmode.md) + +## EnvironmentMode interface + + +Signature: + +```typescript +export interface EnvironmentMode +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [dev](./kibana-plugin-server.environmentmode.dev.md) | boolean | | +| [name](./kibana-plugin-server.environmentmode.name.md) | 'development' | 'production' | | +| [prod](./kibana-plugin-server.environmentmode.prod.md) | boolean | | + diff --git a/docs/development/core/server/kibana-plugin-server.environmentmode.name.md b/docs/development/core/server/kibana-plugin-server.environmentmode.name.md new file mode 100644 index 0000000000000..c3243075866f2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.environmentmode.name.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [EnvironmentMode](./kibana-plugin-server.environmentmode.md) > [name](./kibana-plugin-server.environmentmode.name.md) + +## EnvironmentMode.name property + +Signature: + +```typescript +name: 'development' | 'production'; +``` diff --git a/docs/development/core/server/kibana-plugin-server.environmentmode.prod.md b/docs/development/core/server/kibana-plugin-server.environmentmode.prod.md new file mode 100644 index 0000000000000..86a94775358e9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.environmentmode.prod.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [EnvironmentMode](./kibana-plugin-server.environmentmode.md) > [prod](./kibana-plugin-server.environmentmode.prod.md) + +## EnvironmentMode.prod property + +Signature: + +```typescript +prod: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index bd55924bd45bd..8a259f6870628 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -51,6 +51,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [DiscoveredPlugin](./kibana-plugin-server.discoveredplugin.md) | Small container object used to expose information about discovered plugins that may or may not have been started. | | [ElasticsearchError](./kibana-plugin-server.elasticsearcherror.md) | | | [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) | | +| [EnvironmentMode](./kibana-plugin-server.environmentmode.md) | | | [ErrorHttpResponseOptions](./kibana-plugin-server.errorhttpresponseoptions.md) | HTTP response parameters | | [FakeRequest](./kibana-plugin-server.fakerequest.md) | Fake request object created manually by Kibana plugins. | | [HttpResponseOptions](./kibana-plugin-server.httpresponseoptions.md) | HTTP response parameters | @@ -70,6 +71,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [LogMeta](./kibana-plugin-server.logmeta.md) | Contextual metadata | | [OnPostAuthToolkit](./kibana-plugin-server.onpostauthtoolkit.md) | A tool set defining an outcome of OnPostAuth interceptor for incoming request. | | [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | +| [PackageInfo](./kibana-plugin-server.packageinfo.md) | | | [Plugin](./kibana-plugin-server.plugin.md) | The interface that should be returned by a PluginInitializer. | | [PluginInitializerContext](./kibana-plugin-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. | | [PluginManifest](./kibana-plugin-server.pluginmanifest.md) | Describes the set of required and optional properties plugin can define in its mandatory JSON manifest file. | diff --git a/docs/development/core/server/kibana-plugin-server.packageinfo.branch.md b/docs/development/core/server/kibana-plugin-server.packageinfo.branch.md new file mode 100644 index 0000000000000..36f187180d31b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.packageinfo.branch.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [PackageInfo](./kibana-plugin-server.packageinfo.md) > [branch](./kibana-plugin-server.packageinfo.branch.md) + +## PackageInfo.branch property + +Signature: + +```typescript +branch: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.packageinfo.buildnum.md b/docs/development/core/server/kibana-plugin-server.packageinfo.buildnum.md new file mode 100644 index 0000000000000..c0a231ee27ab0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.packageinfo.buildnum.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [PackageInfo](./kibana-plugin-server.packageinfo.md) > [buildNum](./kibana-plugin-server.packageinfo.buildnum.md) + +## PackageInfo.buildNum property + +Signature: + +```typescript +buildNum: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.packageinfo.buildsha.md b/docs/development/core/server/kibana-plugin-server.packageinfo.buildsha.md new file mode 100644 index 0000000000000..5e8de48067dd0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.packageinfo.buildsha.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [PackageInfo](./kibana-plugin-server.packageinfo.md) > [buildSha](./kibana-plugin-server.packageinfo.buildsha.md) + +## PackageInfo.buildSha property + +Signature: + +```typescript +buildSha: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.packageinfo.dist.md b/docs/development/core/server/kibana-plugin-server.packageinfo.dist.md new file mode 100644 index 0000000000000..e45970780d522 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.packageinfo.dist.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [PackageInfo](./kibana-plugin-server.packageinfo.md) > [dist](./kibana-plugin-server.packageinfo.dist.md) + +## PackageInfo.dist property + +Signature: + +```typescript +dist: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.packageinfo.md b/docs/development/core/server/kibana-plugin-server.packageinfo.md new file mode 100644 index 0000000000000..3ff02c9cda85d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.packageinfo.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [PackageInfo](./kibana-plugin-server.packageinfo.md) + +## PackageInfo interface + + +Signature: + +```typescript +export interface PackageInfo +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [branch](./kibana-plugin-server.packageinfo.branch.md) | string | | +| [buildNum](./kibana-plugin-server.packageinfo.buildnum.md) | number | | +| [buildSha](./kibana-plugin-server.packageinfo.buildsha.md) | string | | +| [dist](./kibana-plugin-server.packageinfo.dist.md) | boolean | | +| [version](./kibana-plugin-server.packageinfo.version.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-server.packageinfo.version.md b/docs/development/core/server/kibana-plugin-server.packageinfo.version.md new file mode 100644 index 0000000000000..e99e3c48d7041 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.packageinfo.version.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [PackageInfo](./kibana-plugin-server.packageinfo.md) > [version](./kibana-plugin-server.packageinfo.version.md) + +## PackageInfo.version property + +Signature: + +```typescript +version: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.plugininitializercontext.env.md b/docs/development/core/server/kibana-plugin-server.plugininitializercontext.env.md index fde398faf132e..fd4caa605c0e5 100644 --- a/docs/development/core/server/kibana-plugin-server.plugininitializercontext.env.md +++ b/docs/development/core/server/kibana-plugin-server.plugininitializercontext.env.md @@ -9,5 +9,6 @@ ```typescript env: { mode: EnvironmentMode; + packageInfo: Readonly; }; ``` diff --git a/docs/development/core/server/kibana-plugin-server.plugininitializercontext.md b/docs/development/core/server/kibana-plugin-server.plugininitializercontext.md index 2bba3d408f68e..e7aa32edaa293 100644 --- a/docs/development/core/server/kibana-plugin-server.plugininitializercontext.md +++ b/docs/development/core/server/kibana-plugin-server.plugininitializercontext.md @@ -17,7 +17,7 @@ export interface PluginInitializerContext | Property | Type | Description | | --- | --- | --- | | [config](./kibana-plugin-server.plugininitializercontext.config.md) | {
create: <T = ConfigSchema>() => Observable<T>;
createIfExists: <T = ConfigSchema>() => Observable<T | undefined>;
} | | -| [env](./kibana-plugin-server.plugininitializercontext.env.md) | {
mode: EnvironmentMode;
} | | +| [env](./kibana-plugin-server.plugininitializercontext.env.md) | {
mode: EnvironmentMode;
packageInfo: Readonly<PackageInfo>;
} | | | [logger](./kibana-plugin-server.plugininitializercontext.logger.md) | LoggerFactory | | | [opaqueId](./kibana-plugin-server.plugininitializercontext.opaqueid.md) | PluginOpaqueId | | diff --git a/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md b/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md index cffc4389becdf..e1ec743ae71cc 100644 --- a/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md +++ b/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md @@ -14,7 +14,7 @@ validate: RouteSchemas | false; ## Remarks -You \*must\* specify a validation schema to be able to read: - url path segments - request query - request body To opt out of validating the request, specify `false`. +You \*must\* specify a validation schema to be able to read: - url path segments - request query - request body To opt out of validating the request, specify `validate: false`. In this case request params, query, and body will be \*\*empty\*\* objects and have no access to raw values. In some cases you may want to use another validation library. To do this, you need to instruct the `@kbn/config-schema` library to output \*\*non-validated values\*\* with setting schema as `schema.object({}, { allowUnknowns: true })`; ## Example @@ -22,7 +22,7 @@ You \*must\* specify a validation schema to be able to read: - url path segments ```ts import { schema } from '@kbn/config-schema'; router.get({ - path: 'path/{id}' + path: 'path/{id}', validate: { params: schema.object({ id: schema.string(), @@ -30,7 +30,33 @@ You \*must\* specify a validation schema to be able to read: - url path segments query: schema.object({...}), body: schema.object({...}), }, - }) +}, +(context, req, res,) { + req.params; // type Readonly<{id: string}> + console.log(req.params.id); // value +}); + +router.get({ + path: 'path/{id}', + validate: false, // handler has no access to params, query, body values. +}, +(context, req, res,) { + req.params; // type Readonly<{}>; + console.log(req.params.id); // undefined +}); + +router.get({ + path: 'path/{id}', + validate: { + // handler has access to raw non-validated params in runtime + params: schema.object({}, { allowUnknowns: true }) + }, +}, +(context, req, res,) { + req.params; // type Readonly<{}>; + console.log(req.params.id); // value + myValidationLibrary.validate({ params: req.params }); +}); ``` diff --git a/docs/infrastructure/infra-ui.asciidoc b/docs/infrastructure/infra-ui.asciidoc index e92f6b3f64205..5c8c50a978d63 100644 --- a/docs/infrastructure/infra-ui.asciidoc +++ b/docs/infrastructure/infra-ui.asciidoc @@ -62,29 +62,30 @@ Select *Auto-refresh* to keep up-to-date metrics information coming in, or *Stop [[infra-configure-source]] === Configure the data to use for your metrics +If your metrics have custom index patterns, or use non-default field settings, you can override the default configuration settings. + The default source configuration for metrics is specified in the {kibana-ref}/infrastructure-ui-settings-kb.html[Metrics app settings] in the {kibana-ref}/settings.html[Kibana configuration file]. The default configuration uses the `metricbeat-*` index pattern to query the data. The default configuration also defines field settings for things like timestamps and container names. -If your metrics have custom index patterns, or use non-default field settings, you can override the default settings. -Click *Configuration* to change the settings. -This opens the *Configure source* fly-out dialog. +To change the configuration settings, click the *Settings* tab. NOTE: These settings are shared with logs. Changes you make here may also affect the settings used by the *Logs* app. -In the *Configure source* dialog, you can change the following values: +In the *Settings* tab, you can change the values in these sections: * *Name*: the name of the source configuration -* *Indices*: the index pattern or patterns in the Elasticsearch indices to read metrics data and log data - from +* *Indices*: the index pattern or patterns in the Elasticsearch indices to read metrics data and log data from * *Fields*: the names of specific fields in the indices that are used to query and interpret the data correctly -TIP: If <> are enabled in your Kibana instance, any configuration changes you make here are specific to the current space. -You can make different subsets of data available by creating multiple spaces with different data source configurations. +When you have completed your changes, click *Apply*. -TIP: If you don't see the *Configuration* option, you may not have sufficient privileges to change the source configuration. +If the fields are greyed out and cannot be edited, you may not have sufficient privileges to change the source configuration. For more information see <>. +TIP: If <> are enabled in your Kibana instance, any configuration changes you make here are specific to the current space. +You can make different subsets of data available by creating multiple spaces with different data source configurations. + [float] [[infra-metrics-explorer]] === Visualize multiple metrics in Metrics Explorer diff --git a/docs/logs/configuring.asciidoc b/docs/logs/configuring.asciidoc index f2aa1e5e17b8f..6b54721f92e89 100644 --- a/docs/logs/configuring.asciidoc +++ b/docs/logs/configuring.asciidoc @@ -1,50 +1,46 @@ [role="xpack"] [[xpack-logs-configuring]] -:ecs-link: {ecs-ref}[Elastic Common Schema (ECS)] +:ecs-base-link: {ecs-ref}/ecs-base.html[base] == Configuring the Logs data The default source configuration for logs is specified in the {kibana-ref}/logs-ui-settings-kb.html[Logs app settings] in the {kibana-ref}/settings.html[Kibana configuration file]. The default configuration uses the `filebeat-*` index pattern to query the data. -The default configuration also defines field settings for things like timestamps and container names, and the default columns to show in the logs pane. +The default configuration also defines field settings for things like timestamps and container names, and the default columns to show in the logs stream. -If your logs have custom index patterns, or use non-default field settings, or contain parsed fields which you want to expose as individual columns, you can override the default settings. -Click *Configuration* to change the settings. -This opens the *Configure source* fly-out dialog. +If your logs have custom index patterns, use non-default field settings, or contain parsed fields which you want to expose as individual columns, you can override the default configuration settings. -NOTE: These settings are shared with metrics. Changes you make here may also affect the settings used by the *Metrics* app. - -TIP: If <> are enabled in your Kibana instance, any configuration changes you make here are specific to the current space. -You can make different subsets of data available by creating multiple spaces with different data source configurations. +To change the configuration settings, click the *Settings* tab. -TIP: If you don't see the *Configuration* option, you may not have sufficient privileges to change the source configuration. -For more information see <>. - -[float] -=== Indices and fields tab +NOTE: These settings are shared with metrics. Changes you make here may also affect the settings used by the *Metrics* app. -In the *Indices and fields* tab, you can change the following values: +In the *Settings* tab, you can change the values in these sections: * *Name*: the name of the source configuration * *Indices*: the index pattern or patterns in the Elasticsearch indices to read metrics data and log data from * *Fields*: the names of specific fields in the indices that are used to query and interpret the data correctly +* *Log columns*: the columns that are shown in the logs stream -[float] -==== Log columns configuration - -In the *Log columns* tab you can change the columns that are displayed in the Logs app. -By default the following columns are shown: +By default the logs stream shows following columns: * *Timestamp*: The timestamp of the log entry from the `timestamp` field. * *Message*: The message extracted from the document. The content of this field depends on the type of log message. -If no special log message type is detected, the {ecs-link} field `message` is used. -// ++ add a better link. The actual page location is ecs-base +If no special log message type is detected, the Elastic Common Schema (ECS) {ecs-base-link} field, `message`, is used. -To add a new column, click *Add column*. +To add a new column to the logs stream, in the *Settings* tab, click *Add column*. In the list of available fields, select the field you want to add. You can start typing a field name in the search box to filter the field list by that name. To remove an existing column, click the *Remove this column* icon -image:logs/images/logs-configure-source-dialog-remove-column-button.png[Remove column]. \ No newline at end of file +image:logs/images/logs-configure-source-dialog-remove-column-button.png[Remove column]. + +When you have completed your changes, click *Apply*. + +If the fields are greyed out and cannot be edited, you may not have sufficient privileges to change the source configuration. +For more information see <>. + +TIP: If <> are enabled in your Kibana instance, any configuration changes you make here are specific to the current space. +You can make different subsets of data available by creating multiple spaces with different data source configurations. + diff --git a/docs/logs/using.asciidoc b/docs/logs/using.asciidoc index bebc225feb0e6..65693f4399e53 100644 --- a/docs/logs/using.asciidoc +++ b/docs/logs/using.asciidoc @@ -43,6 +43,11 @@ To quickly jump to a nearby point in time, click the minimap timeline to the rig Click *Customize* to customize the view. Here, you can set the scale to use for the minimap timeline, choose whether to wrap long lines, and choose your preferred text size. +[float] +=== Configuring the data to use for your logs + +If your logs have custom index patterns, use non-default field settings, or contain parsed fields which you want to expose as individual columns, you can <>. + [float] [[logs-stream]] === Stream or pause logs @@ -70,10 +75,10 @@ To highlight a word or phrase in the logs stream, click *Highlights* and enter y To inspect a log event, hover over it, then click the *View details* icon image:logs/images/logs-view-event.png[View event icon] beside the event. This opens the *Log event document details* fly-out that shows the fields associated with the log event. -To quickly filter the logs stream by one of the field values, click the *View event with filter* icon image:logs/images/logs-view-event-with-filter.png[View event icon] beside the field. +To quickly filter the logs stream by one of the field values, in the log event details, click the *View event with filter* icon image:logs/images/logs-view-event-with-filter.png[View event icon] beside the field. This automatically adds a search filter to the logs stream to filter the entries by this field and value. -In the log event details, click *Actions* to see the other actions related to the event. +To see other actions related to the event, in the log event details, click *Actions*. Depending on the event and the features you have installed and configured, you may also be able to: * Select *View status in Uptime* to <> in the *Uptime* app. diff --git a/packages/kbn-es-query/src/filters/lib/meta_filter.ts b/packages/kbn-es-query/src/filters/lib/meta_filter.ts index 7740dfa644353..8f6aef782cea2 100644 --- a/packages/kbn-es-query/src/filters/lib/meta_filter.ts +++ b/packages/kbn-es-query/src/filters/lib/meta_filter.ts @@ -26,6 +26,12 @@ export interface FilterState { store: FilterStateStore; } +type FilterFormatterFunction = (value: any) => string; +export interface FilterValueFormatter { + convert: FilterFormatterFunction; + getConverterFor: (type: string) => FilterFormatterFunction; +} + export interface FilterMeta { // index and type are optional only because when you create a new filter, there are no defaults index?: string; @@ -34,7 +40,7 @@ export interface FilterMeta { negate: boolean; alias: string | null; key?: string; - value?: string; + value?: string | ((formatter?: FilterValueFormatter) => string); params?: any; } diff --git a/src/core/public/context/context_service.test.ts b/src/core/public/context/context_service.test.ts index d575d57a6b275..934ea77df6fdc 100644 --- a/src/core/public/context/context_service.test.ts +++ b/src/core/public/context/context_service.test.ts @@ -20,17 +20,18 @@ import { PluginOpaqueId } from '../../server'; import { MockContextConstructor } from './context_service.test.mocks'; import { ContextService } from './context_service'; +import { coreMock } from '../mocks'; const pluginDependencies = new Map(); describe('ContextService', () => { describe('#setup()', () => { test('createContextContainer returns a new container configured with pluginDependencies', () => { - const coreId = Symbol(); - const service = new ContextService({ coreId }); + const context = coreMock.createCoreContext(); + const service = new ContextService(context); const setup = service.setup({ pluginDependencies }); expect(setup.createContextContainer()).toBeDefined(); - expect(MockContextConstructor).toHaveBeenCalledWith(pluginDependencies, coreId); + expect(MockContextConstructor).toHaveBeenCalledWith(pluginDependencies, context.coreId); }); }); }); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 193e4dd23f23c..7104b9e10c741 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -20,6 +20,7 @@ import './core.css'; import { CoreId } from '../server'; +import { PackageInfo, EnvironmentMode } from '../server/types'; import { CoreSetup, CoreStart } from '.'; import { ChromeService } from './chrome'; import { FatalErrorsService, FatalErrorsSetup } from './fatal_errors'; @@ -55,6 +56,10 @@ interface Params { /** @internal */ export interface CoreContext { coreId: CoreId; + env: { + mode: Readonly; + packageInfo: Readonly; + }; } /** @internal */ @@ -130,7 +135,8 @@ export class CoreSystem { this.rendering = new RenderingService(); this.application = new ApplicationService(); - this.coreContext = { coreId: Symbol('core') }; + this.coreContext = { coreId: Symbol('core'), env: injectedMetadata.env }; + this.context = new ContextService(this.coreContext); this.plugins = new PluginsService(this.coreContext, injectedMetadata.uiPlugins); diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 5efc94af38246..3d451c7c1f37e 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -60,6 +60,7 @@ import { UiSettingsClient, UiSettingsState, UiSettingsClientContract } from './u import { ApplicationSetup, Capabilities, ApplicationStart } from './application'; import { DocLinksStart } from './doc_links'; import { SavedObjectsStart } from './saved_objects'; +export { PackageInfo, EnvironmentMode } from '../server/types'; import { IContextContainer, IContextProvider, diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index aa9d2a1458735..478285a70771e 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -19,6 +19,7 @@ import { get } from 'lodash'; import { DiscoveredPlugin, PluginName } from '../../server'; +import { EnvironmentMode, PackageInfo } from '../../server/types'; import { UiSettingsState } from '../ui_settings'; import { deepFreeze } from '../../utils/'; import { Capabilities } from '..'; @@ -46,6 +47,10 @@ export interface InjectedMetadataParams { vars: { [key: string]: unknown; }; + env: { + mode: Readonly; + packageInfo: Readonly; + }; uiPlugins: Array<{ id: PluginName; plugin: DiscoveredPlugin; diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 8ce163fd59e14..8345980b6869d 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -18,7 +18,7 @@ */ import { applicationServiceMock } from './application/application_service.mock'; import { chromeServiceMock } from './chrome/chrome_service.mock'; -import { CoreSetup, CoreStart, PluginInitializerContext } from '.'; +import { CoreContext, CoreSetup, CoreStart, PluginInitializerContext } from '.'; import { docLinksServiceMock } from './doc_links/doc_links_service.mock'; import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock'; import { httpServiceMock } from './http/http_service.mock'; @@ -75,9 +75,51 @@ function createCoreStartMock() { return mock; } +function pluginInitializerContextMock() { + const mock: PluginInitializerContext = { + opaqueId: Symbol(), + env: { + mode: { + dev: true, + name: 'development', + prod: false, + }, + packageInfo: { + version: 'version', + branch: 'branch', + buildNum: 100, + buildSha: 'buildSha', + dist: false, + }, + }, + }; + + return mock; +} + +function createCoreContext(): CoreContext { + return { + coreId: Symbol('core context mock'), + env: { + mode: { + dev: true, + name: 'development', + prod: false, + }, + packageInfo: { + version: 'version', + branch: 'branch', + buildNum: 100, + buildSha: 'buildSha', + dist: false, + }, + }, + }; +} export const coreMock = { + createCoreContext, createSetup: createCoreSetupMock, createStart: createCoreStartMock, - createPluginInitializerContext: jest.fn() as jest.Mock, + createPluginInitializerContext: pluginInitializerContextMock, }; diff --git a/src/core/public/plugins/plugin.test.ts b/src/core/public/plugins/plugin.test.ts index 6cbe0c7e0ed82..85de5c6620cc1 100644 --- a/src/core/public/plugins/plugin.test.ts +++ b/src/core/public/plugins/plugin.test.ts @@ -19,6 +19,7 @@ import { mockInitializer, mockPlugin, mockPluginLoader } from './plugin.test.mocks'; import { DiscoveredPlugin } from '../../server'; +import { coreMock } from '../mocks'; import { PluginWrapper } from './plugin'; function createManifest( @@ -36,7 +37,7 @@ function createManifest( let plugin: PluginWrapper>; const opaqueId = Symbol(); -const initializerContext = { opaqueId }; +const initializerContext = coreMock.createPluginInitializerContext(); const addBasePath = (path: string) => path; beforeEach(() => { diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 51bd118d280e3..eae45654fce18 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -19,7 +19,8 @@ import { omit } from 'lodash'; -import { DiscoveredPlugin, PluginOpaqueId } from '../../server'; +import { DiscoveredPlugin } from '../../server'; +import { PluginOpaqueId, PackageInfo, EnvironmentMode } from '../../server/types'; import { CoreContext } from '../core_system'; import { PluginWrapper } from './plugin'; import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service'; @@ -35,6 +36,10 @@ export interface PluginInitializerContext { * A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. */ readonly opaqueId: PluginOpaqueId; + readonly env: { + mode: Readonly; + packageInfo: Readonly; + }; } /** @@ -52,6 +57,7 @@ export function createPluginInitializerContext( ): PluginInitializerContext { return { opaqueId, + env: coreContext.env, }; } diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 358bf71ac9188..cfac4c3648053 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -26,7 +26,7 @@ import { } from './plugins_service.test.mocks'; import { PluginName, DiscoveredPlugin } from 'src/core/server'; -import { CoreContext } from '../core_system'; +import { coreMock } from '../mocks'; import { PluginsService, PluginsServiceStartDeps, @@ -56,7 +56,7 @@ let plugins: Array<{ id: string; plugin: DiscoveredPlugin }>; type DeeplyMocked = { [P in keyof T]: jest.Mocked }; -const mockCoreContext: CoreContext = { coreId: Symbol() }; +const mockCoreContext = coreMock.createCoreContext(); let mockSetupDeps: DeeplyMocked; let mockSetupContext: DeeplyMocked; let mockStartDeps: DeeplyMocked; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 52a4f94945e19..515c02f802c40 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -226,6 +226,11 @@ export interface CoreContext { // // (undocumented) coreId: CoreId; + // (undocumented) + env: { + mode: Readonly; + packageInfo: Readonly; + }; } // @public @@ -379,6 +384,16 @@ export interface DocLinksStart { }; } +// @public (undocumented) +export interface EnvironmentMode { + // (undocumented) + dev: boolean; + // (undocumented) + name: 'development' | 'production'; + // (undocumented) + prod: boolean; +} + // @public export interface ErrorToastOptions { title: string; @@ -642,6 +657,20 @@ export interface OverlayStart { }) => OverlayRef; } +// @public (undocumented) +export interface PackageInfo { + // (undocumented) + branch: string; + // (undocumented) + buildNum: number; + // (undocumented) + buildSha: string; + // (undocumented) + dist: boolean; + // (undocumented) + version: string; +} + // @public export interface Plugin { // (undocumented) @@ -657,6 +686,11 @@ export type PluginInitializer; + packageInfo: Readonly; + }; readonly opaqueId: PluginOpaqueId; } diff --git a/src/core/server/config/env.ts b/src/core/server/config/env.ts index 89d0e68ed276b..bb6064c71742d 100644 --- a/src/core/server/config/env.ts +++ b/src/core/server/config/env.ts @@ -18,25 +18,12 @@ */ import { resolve, dirname } from 'path'; +import { PackageInfo, EnvironmentMode } from './types'; // `require` is necessary for this to work inside x-pack code as well // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('../../../../package.json'); -export interface PackageInfo { - version: string; - branch: string; - buildNum: number; - buildSha: string; - dist: boolean; -} - -export interface EnvironmentMode { - name: 'development' | 'production'; - dev: boolean; - prod: boolean; -} - /** @internal */ export interface EnvOptions { configs: string[]; diff --git a/src/core/server/config/index.ts b/src/core/server/config/index.ts index d27462a86a9c8..491a24b2ab3d6 100644 --- a/src/core/server/config/index.ts +++ b/src/core/server/config/index.ts @@ -21,6 +21,6 @@ export { ConfigService, IConfigService } from './config_service'; export { RawConfigService } from './raw_config_service'; export { Config, ConfigPath, isConfigPath, hasConfigPathIntersection } from './config'; export { ObjectToConfigAdapter } from './object_to_config_adapter'; -export { CliArgs } from './env'; +export { CliArgs, Env } from './env'; -export { Env, EnvironmentMode, PackageInfo } from './env'; +export { EnvironmentMode, PackageInfo } from './types'; diff --git a/src/core/server/config/types.ts b/src/core/server/config/types.ts new file mode 100644 index 0000000000000..117c8bf7c3b44 --- /dev/null +++ b/src/core/server/config/types.ts @@ -0,0 +1,38 @@ +/* + * 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. + */ + +/** + * @public + */ +export interface PackageInfo { + version: string; + branch: string; + buildNum: number; + buildSha: string; + dist: boolean; +} + +/** + * @public + */ +export interface EnvironmentMode { + name: 'development' | 'production'; + dev: boolean; + prod: boolean; +} diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts index 3beb2d17f7b60..bffa23551dd52 100644 --- a/src/core/server/http/router/route.ts +++ b/src/core/server/http/router/route.ts @@ -72,13 +72,18 @@ export interface RouteConfig

+ * console.log(req.params.id); // value + * }); + * + * router.get({ + * path: 'path/{id}', + * validate: false, // handler has no access to params, query, body values. + * }, + * (context, req, res,) { + * req.params; // type Readonly<{}>; + * console.log(req.params.id); // undefined + * }); + * + * router.get({ + * path: 'path/{id}', + * validate: { + * // handler has access to raw non-validated params in runtime + * params: schema.object({}, { allowUnknowns: true }) + * }, + * }, + * (context, req, res,) { + * req.params; // type Readonly<{}>; + * console.log(req.params.id); // value + * myValidationLibrary.validate({ params: req.params }); + * }); * ``` */ validate: RouteSchemas | false; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 369accad43ffc..0941153fee87d 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -50,7 +50,7 @@ import { ContextSetup } from './context'; import { SavedObjectsServiceStart } from './saved_objects'; export { bootstrap } from './bootstrap'; -export { ConfigPath, ConfigService } from './config'; +export { ConfigPath, ConfigService, EnvironmentMode, PackageInfo } from './config'; export { IContextContainer, IContextProvider, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 08b0ebc6501ec..52e999f5e03a7 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -116,7 +116,7 @@ export class LegacyService implements CoreService { this.update$ = this.coreContext.configService.getConfig$().pipe( tap(config => { if (this.kbnServer !== undefined) { - this.kbnServer.applyLoggingConfiguration(config.toRaw()); + this.kbnServer.applyLoggingConfiguration(getLegacyRawConfig(config)); } }), tap({ error: err => this.log.error(err) }), @@ -257,6 +257,10 @@ export class LegacyService implements CoreService { settings, config, { + env: { + mode: this.coreContext.env.mode, + packageInfo: this.coreContext.env.packageInfo, + }, handledConfigPaths: await this.coreContext.configService.getUsedPaths(), setupDeps: { core: coreSetup, diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index f072ae5fd4dbd..c3d524c77402c 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -40,7 +40,7 @@ export function pluginInitializerContextConfigMock(config: T) { return mock; } -function pluginInitializerContextMock(config: T) { +function pluginInitializerContextMock(config: T = {} as T) { const mock: PluginInitializerContext = { opaqueId: Symbol(), logger: loggingServiceMock.create(), @@ -50,6 +50,13 @@ function pluginInitializerContextMock(config: T) { name: 'development', prod: false, }, + packageInfo: { + version: 'version', + branch: 'branch', + buildNum: 100, + buildSha: 'buildSha', + dist: false, + }, }, config: pluginInitializerContextConfigMock(config), }; diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 46432cbb0da75..edcafbb9a3dc3 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -47,7 +47,10 @@ export function createPluginInitializerContext( /** * Environment information that is safe to expose to plugins and may be beneficial for them. */ - env: { mode: coreContext.env.mode }, + env: { + mode: coreContext.env.mode, + packageInfo: coreContext.env.packageInfo, + }, /** * Plugin-scoped logger diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index 4b66c9fb65c18..9a3e922b3cb89 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -20,7 +20,7 @@ import { Observable } from 'rxjs'; import { Type } from '@kbn/config-schema'; -import { ConfigPath, EnvironmentMode } from '../config'; +import { ConfigPath, EnvironmentMode, PackageInfo } from '../config'; import { LoggerFactory } from '../logging'; import { CoreSetup, CoreStart } from '..'; @@ -159,7 +159,10 @@ export interface Plugin< */ export interface PluginInitializerContext { opaqueId: PluginOpaqueId; - env: { mode: EnvironmentMode }; + env: { + mode: EnvironmentMode; + packageInfo: Readonly; + }; logger: LoggerFactory; config: { create: () => Observable; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 24d555d758cb3..3988d8dc7a751 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -597,6 +597,16 @@ export interface ElasticsearchServiceSetup { readonly dataClient$: Observable; } +// @public (undocumented) +export interface EnvironmentMode { + // (undocumented) + dev: boolean; + // (undocumented) + name: 'development' | 'production'; + // (undocumented) + prod: boolean; +} + // @public export interface ErrorHttpResponseOptions { body?: ResponseError; @@ -930,6 +940,20 @@ export interface OnPreAuthToolkit { rewriteUrl: (url: string) => OnPreAuthResult; } +// @public (undocumented) +export interface PackageInfo { + // (undocumented) + branch: string; + // (undocumented) + buildNum: number; + // (undocumented) + buildSha: string; + // (undocumented) + dist: boolean; + // (undocumented) + version: string; +} + // @public export interface Plugin { // (undocumented) @@ -953,6 +977,7 @@ export interface PluginInitializerContext { // (undocumented) env: { mode: EnvironmentMode; + packageInfo: Readonly; }; // (undocumented) logger: LoggerFactory; @@ -1525,6 +1550,5 @@ export interface SessionStorageFactory { // // src/core/server/http/router/response.ts:316:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts // src/core/server/plugins/plugins_service.ts:39:5 - (ae-forgotten-export) The symbol "DiscoveredPluginInternal" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:162:10 - (ae-forgotten-export) The symbol "EnvironmentMode" needs to be exported by the entry point index.d.ts ``` diff --git a/src/core/server/types.ts b/src/core/server/types.ts index d712c804d45d1..46c70a91721b5 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -20,3 +20,4 @@ /** This module is intended for consumption by public to avoid import issues with server-side code */ export { PluginOpaqueId } from './plugins/types'; export * from './saved_objects/types'; +export { EnvironmentMode, PackageInfo } from './config/types'; diff --git a/src/legacy/core_plugins/console/server/api_server/es_6_0/search.js b/src/legacy/core_plugins/console/server/api_server/es_6_0/search.js index f77144b1524c6..71cc9626a273c 100644 --- a/src/legacy/core_plugins/console/server/api_server/es_6_0/search.js +++ b/src/legacy/core_plugins/console/server/api_server/es_6_0/search.js @@ -189,6 +189,7 @@ export default function (api) { stats: [''], timeout: '1s', version: { __one_of: [true, false] }, + track_total_hits: { __one_of: [true, false] }, }, }); diff --git a/src/legacy/core_plugins/data/public/filter/apply_filters/apply_filters_popover.tsx b/src/legacy/core_plugins/data/public/filter/apply_filters/apply_filters_popover.tsx index 4212daff0371e..6270dee72ab05 100644 --- a/src/legacy/core_plugins/data/public/filter/apply_filters/apply_filters_popover.tsx +++ b/src/legacy/core_plugins/data/public/filter/apply_filters/apply_filters_popover.tsx @@ -33,10 +33,13 @@ import { import { Filter } from '@kbn/es-query'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; -import { getFilterDisplayText } from '../filter_bar/filter_view'; +import { IndexPattern } from '../../index_patterns'; +import { getDisplayValueFromFilter } from '../filter_bar/filter_editor/lib/filter_editor_utils'; +import { getFilterDisplayText } from '../filter_bar/filter_editor/lib/get_filter_display_text'; interface Props { filters: Filter[]; + indexPatterns: IndexPattern[]; onCancel: () => void; onSubmit: (filters: Filter[]) => void; } @@ -57,6 +60,11 @@ export class ApplyFiltersPopover extends Component { }; } + private getLabel(filter: Filter) { + const filterDisplayValue = getDisplayValueFromFilter(filter, this.props.indexPatterns); + return getFilterDisplayText(filter, filterDisplayValue); + } + public render() { if (this.props.filters.length === 0) { return ''; @@ -67,7 +75,7 @@ export class ApplyFiltersPopover extends Component { {this.props.filters.map((filter, i) => ( this.toggleFilterSelected(i)} /> diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts index f0628f03c173e..c6bd435708782 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts @@ -31,7 +31,7 @@ import { PhrasesFilter, RangeFilter, } from '@kbn/es-query'; -import { omit } from 'lodash'; +import { omit, get } from 'lodash'; import { Ipv4Address } from '../../../../../../../../plugins/kibana_utils/public'; import { Field, IndexPattern, isFilterable } from '../../../../index_patterns'; import { FILTER_OPERATORS, Operator } from './filter_operators'; @@ -43,6 +43,27 @@ export function getIndexPatternFromFilter( return indexPatterns.find(indexPattern => indexPattern.id === filter.meta.index); } +function getValueFormatter(indexPattern?: IndexPattern, key?: string) { + if (!indexPattern || !key) return; + let format = get(indexPattern, ['fields', 'byName', key, 'format']); + if (!format && indexPattern.fields.getByName) { + // TODO: Why is indexPatterns sometimes a map and sometimes an array? + format = (indexPattern.fields.getByName(key) as Field).format; + } + return format; +} + +export function getDisplayValueFromFilter(filter: Filter, indexPatterns: IndexPattern[]): string { + const indexPattern = getIndexPatternFromFilter(filter, indexPatterns); + + if (typeof filter.meta.value === 'function') { + const valueFormatter: any = getValueFormatter(indexPattern, filter.meta.key); + return filter.meta.value(valueFormatter); + } else { + return filter.meta.value || ''; + } +} + export function getFieldFromFilter(filter: FieldFilter, indexPattern: IndexPattern) { return indexPattern.fields.find(field => field.name === filter.meta.key); } diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/get_filter_display_text.ts b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/get_filter_display_text.ts new file mode 100644 index 0000000000000..73ee1a69a2ce3 --- /dev/null +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/get_filter_display_text.ts @@ -0,0 +1,53 @@ +/* + * 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 { Filter } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { existsOperator, isOneOfOperator } from './filter_operators'; + +export function getFilterDisplayText(filter: Filter, filterDisplayName: string) { + const prefix = filter.meta.negate + ? ` ${i18n.translate('data.filter.filterBar.negatedFilterPrefix', { + defaultMessage: 'NOT ', + })}` + : ''; + + if (filter.meta.alias !== null) { + return `${prefix}${filter.meta.alias}`; + } + + switch (filter.meta.type) { + case 'exists': + return `${prefix}${filter.meta.key} ${existsOperator.message}`; + case 'geo_bounding_box': + return `${prefix}${filter.meta.key}: ${filterDisplayName}`; + case 'geo_polygon': + return `${prefix}${filter.meta.key}: ${filterDisplayName}`; + case 'phrase': + return `${prefix}${filter.meta.key}: ${filterDisplayName}`; + case 'phrases': + return `${prefix}${filter.meta.key} ${isOneOfOperator.message} ${filterDisplayName}`; + case 'query_string': + return `${prefix}${filterDisplayName}`; + case 'range': + return `${prefix}${filter.meta.key}: ${filterDisplayName}`; + default: + return `${prefix}${JSON.stringify(filter.query)}`; + } +} diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_item.tsx b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_item.tsx index 21259cec51d3a..0521b4e45aeb0 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_item.tsx +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_item.tsx @@ -32,6 +32,7 @@ import { UiSettingsClientContract } from 'src/core/public'; import { IndexPattern } from '../../index_patterns'; import { FilterEditor } from './filter_editor'; import { FilterView } from './filter_view'; +import { getDisplayValueFromFilter } from './filter_editor/lib/filter_editor_utils'; interface Props { id: string; @@ -67,8 +68,9 @@ class FilterItemUI extends Component { this.props.className ); + const displayName = getDisplayValueFromFilter(filter, this.props.indexPatterns); const dataTestSubjKey = filter.meta.key ? `filter-key-${filter.meta.key}` : ''; - const dataTestSubjValue = filter.meta.value ? `filter-value-${filter.meta.value}` : ''; + const dataTestSubjValue = filter.meta.value ? `filter-value-${displayName}` : ''; const dataTestSubjDisabled = `filter-${ this.props.filter.meta.disabled ? 'disabled' : 'enabled' }`; @@ -76,6 +78,7 @@ class FilterItemUI extends Component { const badge = ( this.props.onRemove()} onClick={this.togglePopover} diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_view/index.tsx b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_view/index.tsx index dca2101d435cf..a7ea23efce49e 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_view/index.tsx +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_view/index.tsx @@ -21,20 +21,25 @@ import { EuiBadge } from '@elastic/eui'; import { Filter, isFilterPinned } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import React, { SFC } from 'react'; -import { existsOperator, isOneOfOperator } from '../filter_editor/lib/filter_operators'; +import { getFilterDisplayText } from '../filter_editor/lib/get_filter_display_text'; interface Props { filter: Filter; + displayName: string; [propName: string]: any; } -export const FilterView: SFC = ({ filter, iconOnClick, onClick, ...rest }: Props) => { - let title = `Filter: ${getFilterDisplayText(filter)}. ${i18n.translate( - 'data.filter.filterBar.moreFilterActionsMessage', - { - defaultMessage: 'Select for more filter actions.', - } - )}`; +export const FilterView: SFC = ({ + filter, + iconOnClick, + onClick, + displayName, + ...rest +}: Props) => { + let title = i18n.translate('data.filter.filterBar.moreFilterActionsMessage', { + defaultMessage: 'Filter: {displayText}. Select for more filter actions.', + values: { displayText: getFilterDisplayText(filter, displayName) }, + }); if (isFilterPinned(filter)) { title = `${i18n.translate('data.filter.filterBar.pinnedFilterPrefix', { @@ -67,38 +72,7 @@ export const FilterView: SFC = ({ filter, iconOnClick, onClick, ...rest } })} {...rest} > - {getFilterDisplayText(filter)} + {getFilterDisplayText(filter, displayName)} ); }; - -export function getFilterDisplayText(filter: Filter) { - const prefix = filter.meta.negate - ? ` ${i18n.translate('data.filter.filterBar.negatedFilterPrefix', { - defaultMessage: 'NOT ', - })}` - : ''; - - if (filter.meta.alias !== null) { - return `${prefix}${filter.meta.alias}`; - } - - switch (filter.meta.type) { - case 'exists': - return `${prefix}${filter.meta.key} ${existsOperator.message}`; - case 'geo_bounding_box': - return `${prefix}${filter.meta.key}: ${filter.meta.value}`; - case 'geo_polygon': - return `${prefix}${filter.meta.key}: ${filter.meta.value}`; - case 'phrase': - return `${prefix}${filter.meta.key}: ${filter.meta.value}`; - case 'phrases': - return `${prefix}${filter.meta.key} ${isOneOfOperator.message} ${filter.meta.value}`; - case 'query_string': - return `${prefix}${filter.meta.value}`; - case 'range': - return `${prefix}${filter.meta.key}: ${filter.meta.value}`; - default: - return `${prefix}${JSON.stringify(filter.query)}`; - } -} diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.test.ts index 3980251ce0043..21c51c9f68f41 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.test.ts @@ -26,9 +26,7 @@ import { Filter, FilterStateStore } from '@kbn/es-query'; import { FilterStateManager } from './filter_state_manager'; import { FilterManager } from './filter_manager'; -import { IndexPatterns } from '../../index_patterns'; import { getFilter } from './test_helpers/get_stub_filter'; -import { StubIndexPatterns } from './test_helpers/stub_index_pattern'; import { StubState } from './test_helpers/stub_state'; import { getFiltersArray } from './test_helpers/get_filters_array'; @@ -48,18 +46,13 @@ describe('filter_manager', () => { let updateListener: sinon.SinonSpy; let filterManager: FilterManager; - let indexPatterns: StubIndexPatterns; let readyFilters: Filter[]; beforeEach(() => { updateListener = sinon.stub(); appStateStub = new StubState(); globalStateStub = new StubState(); - indexPatterns = new StubIndexPatterns(); - filterManager = new FilterManager( - (indexPatterns as unknown) as IndexPatterns, - setupMock.uiSettings - ); + filterManager = new FilterManager(setupMock.uiSettings); readyFilters = getFiltersArray(); // FilterStateManager is tested indirectly. @@ -81,7 +74,7 @@ describe('filter_manager', () => { fetchSubscription.unsubscribe(); } - await filterManager.removeAll(); + filterManager.removeAll(); }); describe('observing', () => { @@ -135,7 +128,7 @@ describe('filter_manager', () => { test('app state should be set', async () => { updateSubscription = filterManager.getUpdates$().subscribe(updateListener); const f1 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); - await filterManager.setFilters([f1]); + filterManager.setFilters([f1]); expect(filterManager.getAppFilters()).toHaveLength(1); expect(filterManager.getGlobalFilters()).toHaveLength(0); expect(filterManager.getFilters()).toHaveLength(1); @@ -149,7 +142,7 @@ describe('filter_manager', () => { test('global state should be set', async () => { updateSubscription = filterManager.getUpdates$().subscribe(updateListener); const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); - await filterManager.setFilters([f1]); + filterManager.setFilters([f1]); expect(filterManager.getAppFilters()).toHaveLength(0); expect(filterManager.getGlobalFilters()).toHaveLength(1); expect(filterManager.getFilters()).toHaveLength(1); @@ -164,7 +157,7 @@ describe('filter_manager', () => { updateSubscription = filterManager.getUpdates$().subscribe(updateListener); const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.APP_STATE, false, false, 'gender', 'FEMALE'); - await filterManager.setFilters([f1, f2]); + filterManager.setFilters([f1, f2]); expect(filterManager.getAppFilters()).toHaveLength(1); expect(filterManager.getGlobalFilters()).toHaveLength(1); expect(filterManager.getFilters()).toHaveLength(2); @@ -183,8 +176,8 @@ describe('filter_manager', () => { const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.APP_STATE, false, false, 'gender', 'FEMALE'); - await filterManager.setFilters([f1]); - await filterManager.setFilters([f2]); + filterManager.setFilters([f1]); + filterManager.setFilters([f2]); expect(filterManager.getAppFilters()).toHaveLength(1); expect(filterManager.getGlobalFilters()).toHaveLength(0); @@ -204,7 +197,7 @@ describe('filter_manager', () => { const fetchStub = jest.fn(); const f1 = getFilter(FilterStateStore.GLOBAL_STATE, true, false, 'age', 34); - await filterManager.setFilters([f1]); + filterManager.setFilters([f1]); filterManager.getUpdates$().subscribe({ next: updateStub, @@ -216,7 +209,7 @@ describe('filter_manager', () => { const f2 = _.cloneDeep(f1); f2.meta.negate = true; - await filterManager.setFilters([f2]); + filterManager.setFilters([f2]); // this time, events should be emitted expect(fetchStub).toBeCalledTimes(0); @@ -228,7 +221,7 @@ describe('filter_manager', () => { test('app state should accept a single filter', async function() { updateSubscription = filterManager.getUpdates$().subscribe(updateListener); const f1 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); - await filterManager.addFilters(f1); + filterManager.addFilters(f1); expect(filterManager.getAppFilters()).toHaveLength(1); expect(filterManager.getGlobalFilters()).toHaveLength(0); expect(updateListener.callCount).toBe(1); @@ -238,8 +231,8 @@ describe('filter_manager', () => { test('app state should accept array', async () => { const f1 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.APP_STATE, false, false, 'gender', 'female'); - await filterManager.addFilters([f1]); - await filterManager.addFilters([f2]); + filterManager.addFilters([f1]); + filterManager.addFilters([f2]); expect(filterManager.getAppFilters()).toHaveLength(2); expect(filterManager.getGlobalFilters()).toHaveLength(0); expect(appStateStub.filters.length).toBe(2); @@ -248,7 +241,7 @@ describe('filter_manager', () => { test('global state should accept a single filer', async () => { updateSubscription = filterManager.getUpdates$().subscribe(updateListener); const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); - await filterManager.addFilters(f1); + filterManager.addFilters(f1); expect(filterManager.getAppFilters()).toHaveLength(0); expect(filterManager.getGlobalFilters()).toHaveLength(1); expect(updateListener.callCount).toBe(1); @@ -258,7 +251,7 @@ describe('filter_manager', () => { test('global state should be accept array', async () => { const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'gender', 'female'); - await filterManager.addFilters([f1, f2]); + filterManager.addFilters([f1, f2]); expect(filterManager.getAppFilters()).toHaveLength(0); expect(filterManager.getGlobalFilters()).toHaveLength(2); expect(globalStateStub.filters.length).toBe(2); @@ -268,7 +261,7 @@ describe('filter_manager', () => { updateSubscription = filterManager.getUpdates$().subscribe(updateListener); const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'gender', 'female'); - await filterManager.addFilters([f1, f2]); + filterManager.addFilters([f1, f2]); expect(filterManager.getAppFilters()).toHaveLength(0); expect(filterManager.getGlobalFilters()).toHaveLength(2); expect(updateListener.callCount).toBe(1); @@ -278,7 +271,7 @@ describe('filter_manager', () => { updateSubscription = filterManager.getUpdates$().subscribe(updateListener); const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); - await filterManager.addFilters([f1, f2]); + filterManager.addFilters([f1, f2]); // FILTER SHOULD BE ADDED ONLY ONCE, TO GLOBAL expect(filterManager.getAppFilters()).toHaveLength(0); @@ -290,7 +283,7 @@ describe('filter_manager', () => { updateSubscription = filterManager.getUpdates$().subscribe(updateListener); const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 38); const f2 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); - await filterManager.addFilters([f1, f2]); + filterManager.addFilters([f1, f2]); // FILTER SHOULD BE ADDED TWICE expect(filterManager.getAppFilters()).toHaveLength(1); @@ -302,7 +295,7 @@ describe('filter_manager', () => { const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 38); f1.$state = undefined; - await filterManager.addFilters([f1], true); + filterManager.addFilters([f1], true); // FILTER SHOULD BE GLOBAL const f1Output = filterManager.getFilters()[0]; @@ -316,7 +309,7 @@ describe('filter_manager', () => { const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 38); f1.$state = undefined; - await filterManager.addFilters([f1], false); + filterManager.addFilters([f1], false); // FILTER SHOULD BE APP const f1Output = filterManager.getFilters()[0]; @@ -328,8 +321,8 @@ describe('filter_manager', () => { test('should return app and global filters', async function() { const filters = getFiltersArray(); - await filterManager.addFilters(filters[0], false); - await filterManager.addFilters(filters[1], true); + filterManager.addFilters(filters[0], false); + filterManager.addFilters(filters[1], true); // global filters should be listed first let res = filterManager.getFilters(); @@ -343,16 +336,16 @@ describe('filter_manager', () => { expect(res[1].query).toEqual(filters[0].query); // should return updated version of filters - await filterManager.addFilters(filters[2], false); + filterManager.addFilters(filters[2], false); res = filterManager.getFilters(); expect(res).toHaveLength(3); }); test('should skip appStateStub filters that match globalStateStub filters', async function() { - await filterManager.addFilters(readyFilters, true); + filterManager.addFilters(readyFilters, true); const appFilter = _.cloneDeep(readyFilters[1]); - await filterManager.addFilters(appFilter, false); + filterManager.addFilters(appFilter, false); // global filters should be listed first const res = filterManager.getFilters(); @@ -367,7 +360,7 @@ describe('filter_manager', () => { const filter = _.cloneDeep(readyFilters[0]); filter.meta.negate = false; - await filterManager.addFilters(filter); + filterManager.addFilters(filter); expect(filterManager.getFilters()).toHaveLength(1); expect(filterManager.getFilters()[0]).toEqual(filter); @@ -375,7 +368,7 @@ describe('filter_manager', () => { const negatedFilter = _.cloneDeep(readyFilters[0]); negatedFilter.meta.negate = true; - await filterManager.addFilters(negatedFilter); + filterManager.addFilters(negatedFilter); // The negated filter should overwrite the positive one expect(globalStateStub.filters.length).toBe(1); expect(filterManager.getFilters()).toHaveLength(1); @@ -387,7 +380,7 @@ describe('filter_manager', () => { const negatedFilter = _.cloneDeep(readyFilters[0]); negatedFilter.meta.negate = true; - await filterManager.addFilters(negatedFilter); + filterManager.addFilters(negatedFilter); // The negated filter should overwrite the positive one expect(globalStateStub.filters.length).toBe(1); @@ -397,7 +390,7 @@ describe('filter_manager', () => { const filter = _.cloneDeep(readyFilters[0]); filter.meta.negate = false; - await filterManager.addFilters(filter); + filterManager.addFilters(filter); expect(globalStateStub.filters.length).toBe(1); expect(globalStateStub.filters[0]).toEqual(filter); }); @@ -414,7 +407,7 @@ describe('filter_manager', () => { next: fetchStub, }); - await filterManager.addFilters(readyFilters); + filterManager.addFilters(readyFilters); // updates should trigger state saves expect(appStateStub.save.callCount).toBe(1); @@ -429,26 +422,26 @@ describe('filter_manager', () => { describe('filter reconciliation', function() { test('should de-dupe appStateStub filters being added', async function() { const newFilter = _.cloneDeep(readyFilters[1]); - await filterManager.addFilters(readyFilters, false); + filterManager.addFilters(readyFilters, false); expect(appStateStub.filters.length).toBe(3); - await filterManager.addFilters(newFilter, false); + filterManager.addFilters(newFilter, false); expect(appStateStub.filters.length).toBe(3); }); test('should de-dupe globalStateStub filters being added', async function() { const newFilter = _.cloneDeep(readyFilters[1]); - await filterManager.addFilters(readyFilters, true); + filterManager.addFilters(readyFilters, true); expect(globalStateStub.filters.length).toBe(3); - await filterManager.addFilters(newFilter, true); + filterManager.addFilters(newFilter, true); expect(globalStateStub.filters.length).toBe(3); }); test('should de-dupe globalStateStub filters being set', async () => { const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); const f2 = _.cloneDeep(f1); - await filterManager.setFilters([f1, f2]); + filterManager.setFilters([f1, f2]); expect(filterManager.getAppFilters()).toHaveLength(0); expect(filterManager.getGlobalFilters()).toHaveLength(1); expect(filterManager.getFilters()).toHaveLength(1); @@ -457,7 +450,7 @@ describe('filter_manager', () => { test('should de-dupe appStateStub filters being set', async () => { const f1 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); const f2 = _.cloneDeep(f1); - await filterManager.setFilters([f1, f2]); + filterManager.setFilters([f1, f2]); expect(filterManager.getAppFilters()).toHaveLength(1); expect(filterManager.getGlobalFilters()).toHaveLength(0); expect(filterManager.getFilters()).toHaveLength(1); @@ -465,14 +458,14 @@ describe('filter_manager', () => { test('should mutate global filters on appStateStub filter changes', async function() { const idx = 1; - await filterManager.addFilters(readyFilters, true); + filterManager.addFilters(readyFilters, true); const appFilter = _.cloneDeep(readyFilters[idx]); appFilter.meta.negate = true; appFilter.$state = { store: FilterStateStore.APP_STATE, }; - await filterManager.addFilters(appFilter); + filterManager.addFilters(appFilter); const res = filterManager.getFilters(); expect(res).toHaveLength(3); _.each(res, function(filter, i) { @@ -483,13 +476,13 @@ describe('filter_manager', () => { }); test('should merge conflicting appStateStub filters', async function() { - await filterManager.addFilters(readyFilters, true); + filterManager.addFilters(readyFilters, true); const appFilter = _.cloneDeep(readyFilters[1]); appFilter.meta.negate = true; appFilter.$state = { store: FilterStateStore.APP_STATE, }; - await filterManager.addFilters(appFilter, false); + filterManager.addFilters(appFilter, false); // global filters should be listed first const res = filterManager.getFilters(); @@ -508,8 +501,8 @@ describe('filter_manager', () => { f.meta.disabled = true; return f; }); - await filterManager.addFilters(disabledFilters, true); - await filterManager.addFilters(readyFilters, true); + filterManager.addFilters(disabledFilters, true); + filterManager.addFilters(readyFilters, true); const res = filterManager.getFilters(); expect(res).toHaveLength(3); @@ -527,8 +520,8 @@ describe('filter_manager', () => { f.meta.disabled = true; return f; }); - await filterManager.addFilters(disabledFilters, true); - await filterManager.addFilters(readyFilters, false); + filterManager.addFilters(disabledFilters, true); + filterManager.addFilters(readyFilters, false); const res = filterManager.getFilters(); expect(res).toHaveLength(3); @@ -543,7 +536,7 @@ describe('filter_manager', () => { describe('remove filters', () => { test('remove on empty should do nothing and not fire events', async () => { updateSubscription = filterManager.getUpdates$().subscribe(updateListener); - await filterManager.removeAll(); + filterManager.removeAll(); expect(updateListener.called).toBeFalsy(); expect(filterManager.getFilters()).toHaveLength(0); }); @@ -551,10 +544,10 @@ describe('filter_manager', () => { test('remove on full should clean and fire events', async () => { const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.APP_STATE, false, false, 'gender', 'FEMALE'); - await filterManager.setFilters([f1, f2]); + filterManager.setFilters([f1, f2]); updateSubscription = filterManager.getUpdates$().subscribe(updateListener); - await filterManager.removeAll(); + filterManager.removeAll(); expect(updateListener.called).toBeTruthy(); expect(filterManager.getFilters()).toHaveLength(0); }); @@ -563,7 +556,7 @@ describe('filter_manager', () => { const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.APP_STATE, false, false, 'gender', 'FEMALE'); const f3 = getFilter(FilterStateStore.APP_STATE, false, false, 'country', 'US'); - await filterManager.setFilters([f1, f2]); + filterManager.setFilters([f1, f2]); expect(filterManager.getFilters()).toHaveLength(2); updateSubscription = filterManager.getUpdates$().subscribe(updateListener); @@ -576,7 +569,7 @@ describe('filter_manager', () => { const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.APP_STATE, false, false, 'gender', 'FEMALE'); const f3 = getFilter(FilterStateStore.APP_STATE, false, false, 'country', 'US'); - await filterManager.setFilters([f1, f2, f3]); + filterManager.setFilters([f1, f2, f3]); expect(filterManager.getFilters()).toHaveLength(3); updateSubscription = filterManager.getUpdates$().subscribe(updateListener); @@ -586,14 +579,14 @@ describe('filter_manager', () => { }); test('should remove the filter from appStateStub', async function() { - await filterManager.addFilters(readyFilters, false); + filterManager.addFilters(readyFilters, false); expect(appStateStub.filters).toHaveLength(3); filterManager.removeFilter(readyFilters[0]); expect(appStateStub.filters).toHaveLength(2); }); test('should remove the filter from globalStateStub', async function() { - await filterManager.addFilters(readyFilters, true); + filterManager.addFilters(readyFilters, true); expect(globalStateStub.filters).toHaveLength(3); filterManager.removeFilter(readyFilters[0]); expect(globalStateStub.filters).toHaveLength(2); @@ -603,7 +596,7 @@ describe('filter_manager', () => { const updateStub = jest.fn(); const fetchStub = jest.fn(); - await filterManager.addFilters(readyFilters, false); + filterManager.addFilters(readyFilters, false); filterManager.getUpdates$().subscribe({ next: updateStub, @@ -621,8 +614,8 @@ describe('filter_manager', () => { }); test('should remove matching filters', async function() { - await filterManager.addFilters([readyFilters[0], readyFilters[1]], true); - await filterManager.addFilters([readyFilters[2]], false); + filterManager.addFilters([readyFilters[0], readyFilters[1]], true); + filterManager.addFilters([readyFilters[2]], false); filterManager.removeFilter(readyFilters[0]); @@ -631,8 +624,8 @@ describe('filter_manager', () => { }); test('should remove matching filters by comparison', async function() { - await filterManager.addFilters([readyFilters[0], readyFilters[1]], true); - await filterManager.addFilters([readyFilters[2]], false); + filterManager.addFilters([readyFilters[0], readyFilters[1]], true); + filterManager.addFilters([readyFilters[2]], false); filterManager.removeFilter(_.cloneDeep(readyFilters[0])); @@ -645,8 +638,8 @@ describe('filter_manager', () => { }); test('should do nothing with a non-matching filter', async function() { - await filterManager.addFilters([readyFilters[0], readyFilters[1]], true); - await filterManager.addFilters([readyFilters[2]], false); + filterManager.addFilters([readyFilters[0], readyFilters[1]], true); + filterManager.addFilters([readyFilters[2]], false); const missedFilter = _.cloneDeep(readyFilters[0]); missedFilter.meta.negate = !readyFilters[0].meta.negate; @@ -657,12 +650,12 @@ describe('filter_manager', () => { }); test('should remove all the filters from both states', async function() { - await filterManager.addFilters([readyFilters[0], readyFilters[1]], true); - await filterManager.addFilters([readyFilters[2]], false); + filterManager.addFilters([readyFilters[0], readyFilters[1]], true); + filterManager.addFilters([readyFilters[2]], false); expect(globalStateStub.filters).toHaveLength(2); expect(appStateStub.filters).toHaveLength(1); - await filterManager.removeAll(); + filterManager.removeAll(); expect(globalStateStub.filters).toHaveLength(0); expect(appStateStub.filters).toHaveLength(0); }); @@ -670,7 +663,7 @@ describe('filter_manager', () => { describe('invert', () => { test('should fire the update and fetch events', async function() { - await filterManager.addFilters(readyFilters); + filterManager.addFilters(readyFilters); expect(filterManager.getFilters()).toHaveLength(3); const updateStub = jest.fn(); @@ -684,7 +677,7 @@ describe('filter_manager', () => { }); readyFilters[1].meta.negate = !readyFilters[1].meta.negate; - await filterManager.addFilters(readyFilters[1]); + filterManager.addFilters(readyFilters[1]); expect(filterManager.getFilters()).toHaveLength(3); expect(fetchStub).toBeCalledTimes(1); expect(updateStub).toBeCalledTimes(1); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.ts index 6e5fa37113451..b3d6bd6873f50 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.ts @@ -29,17 +29,14 @@ import { mapAndFlattenFilters } from './lib/map_and_flatten_filters'; import { uniqFilters } from './lib/uniq_filters'; import { onlyDisabledFiltersChanged } from './lib/only_disabled'; import { PartitionedFilters } from './partitioned_filters'; -import { IndexPatterns } from '../../index_patterns'; export class FilterManager { - private indexPatterns: IndexPatterns; private filters: Filter[] = []; private updated$: Subject = new Subject(); private fetch$: Subject = new Subject(); private uiSettings: UiSettingsClientContract; - constructor(indexPatterns: IndexPatterns, uiSettings: UiSettingsClientContract) { - this.indexPatterns = indexPatterns; + constructor(uiSettings: UiSettingsClientContract) { this.uiSettings = uiSettings; } @@ -127,7 +124,7 @@ export class FilterManager { /* Setters */ - public async addFilters(filters: Filter[] | Filter, pinFilterStatus?: boolean) { + public addFilters(filters: Filter[] | Filter, pinFilterStatus?: boolean) { if (!Array.isArray(filters)) { filters = [filters]; } @@ -145,7 +142,7 @@ export class FilterManager { const store = pinFilterStatus ? FilterStateStore.GLOBAL_STATE : FilterStateStore.APP_STATE; FilterManager.setFiltersStore(filters, store); - const mappedFilters = await mapAndFlattenFilters(this.indexPatterns, filters); + const mappedFilters = mapAndFlattenFilters(filters); // This is where we add new filters to the correct place (app \ global) const newPartitionedFilters = FilterManager.partitionFilters(mappedFilters); @@ -157,8 +154,8 @@ export class FilterManager { this.handleStateUpdate(newFilters); } - public async setFilters(newFilters: Filter[]) { - const mappedFilters = await mapAndFlattenFilters(this.indexPatterns, newFilters); + public setFilters(newFilters: Filter[]) { + const mappedFilters = mapAndFlattenFilters(newFilters); const newPartitionedFilters = FilterManager.partitionFilters(mappedFilters); const mergedFilters = this.mergeIncomingFilters(newPartitionedFilters); this.handleStateUpdate(mergedFilters); @@ -176,8 +173,8 @@ export class FilterManager { } } - public async removeAll() { - await this.setFilters([]); + public removeAll() { + this.setFilters([]); } public static setFiltersStore(filters: Filter[], store: FilterStateStore) { diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.test.ts index 84f336506b375..d1cad9a812399 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.test.ts @@ -22,11 +22,9 @@ import sinon from 'sinon'; import { FilterStateStore } from '@kbn/es-query'; import { FilterStateManager } from './filter_state_manager'; -import { IndexPatterns } from '../../index_patterns'; import { StubState } from './test_helpers/stub_state'; import { getFilter } from './test_helpers/get_stub_filter'; import { FilterManager } from './filter_manager'; -import { StubIndexPatterns } from './test_helpers/stub_index_pattern'; import { coreMock } from '../../../../../../core/public/mocks'; const setupMock = coreMock.createSetup(); @@ -44,11 +42,7 @@ describe('filter_state_manager', () => { beforeEach(() => { appStateStub = new StubState(); globalStateStub = new StubState(); - const indexPatterns = new StubIndexPatterns(); - filterManager = new FilterManager( - (indexPatterns as unknown) as IndexPatterns, - setupMock.uiSettings - ); + filterManager = new FilterManager(setupMock.uiSettings); }); describe('app_state_undefined', () => { @@ -81,7 +75,7 @@ describe('filter_state_manager', () => { const f1 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); - await filterManager.setFilters([f1, f2]); + filterManager.setFilters([f1, f2]); sinon.assert.notCalled(appStateStub.save); sinon.assert.calledOnce(globalStateStub.save); @@ -89,10 +83,11 @@ describe('filter_state_manager', () => { }); describe('app_state_defined', () => { + let filterStateManager: FilterStateManager; beforeEach(() => { // FilterStateManager is tested indirectly. // Therefore, we don't need it's instance. - new FilterStateManager( + filterStateManager = new FilterStateManager( globalStateStub, () => { return appStateStub; @@ -101,6 +96,10 @@ describe('filter_state_manager', () => { ); }); + afterEach(() => { + filterStateManager.destroy(); + }); + test('should update filter manager global filters', done => { const f1 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); globalStateStub.filters.push(f1); @@ -123,27 +122,27 @@ describe('filter_state_manager', () => { }, 100); }); - test('should update URL when filter manager filters are set', async () => { + test('should update URL when filter manager filters are set', () => { appStateStub.save = sinon.stub(); globalStateStub.save = sinon.stub(); const f1 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); - await filterManager.setFilters([f1, f2]); + filterManager.setFilters([f1, f2]); sinon.assert.calledOnce(appStateStub.save); sinon.assert.calledOnce(globalStateStub.save); }); - test('should update URL when filter manager filters are added', async () => { + test('should update URL when filter manager filters are added', () => { appStateStub.save = sinon.stub(); globalStateStub.save = sinon.stub(); const f1 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); const f2 = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); - await filterManager.addFilters([f1, f2]); + filterManager.addFilters([f1, f2]); sinon.assert.calledOnce(appStateStub.save); sinon.assert.calledOnce(globalStateStub.save); @@ -156,13 +155,13 @@ describe('filter_state_manager', () => { ** would cause filter state manager detects those changes ** And triggers *another* filter manager update. */ - test('should NOT re-trigger filter manager', async done => { + test('should NOT re-trigger filter manager', done => { const f1 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); filterManager.setFilters([f1]); const setFiltersSpy = sinon.spy(filterManager, 'setFilters'); f1.meta.negate = true; - await filterManager.setFilters([f1]); + filterManager.setFilters([f1]); setTimeout(() => { expect(setFiltersSpy.callCount).toEqual(1); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/generate_mapping_chain.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/generate_mapping_chain.test.ts index 7f9dbb1ea2609..c0c509634aba2 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/generate_mapping_chain.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/generate_mapping_chain.test.ts @@ -31,27 +31,27 @@ describe('filter manager utilities', () => { }); describe('generateMappingChain()', () => { - test('should create a chaining function which calls the next function if the promise is rejected', async () => { + test('should create a chaining function which calls the next function if the error is thrown', async () => { const filter: Filter = buildEmptyFilter(true); - mapping.rejects(filter); - next.resolves('good'); + mapping.throws(filter); + next.returns('good'); const chain = generateMappingChain(mapping, next); - const result = await chain(filter); + const result = chain(filter); expect(result).toBe('good'); sinon.assert.calledOnce(next); }); - test('should create a chaining function which DOES NOT call the next function if the result is resolved', async () => { + test('should create a chaining function which DOES NOT call the next function if the result is returned', async () => { const filter: Filter = buildEmptyFilter(true); - mapping.resolves('good'); - next.resolves('bad'); + mapping.returns('good'); + next.returns('bad'); const chain = generateMappingChain(mapping, next); - const result = await chain(filter); + const result = chain(filter); expect(result).toBe('good'); }); @@ -59,10 +59,10 @@ describe('filter manager utilities', () => { test('should resolve result for the mapping function', async () => { const filter: Filter = buildEmptyFilter(true); - mapping.resolves({ key: 'test', value: 'example' }); + mapping.returns({ key: 'test', value: 'example' }); const chain = generateMappingChain(mapping, next); - const result = await chain(filter); + const result = chain(filter); sinon.assert.notCalled(next); expect(result).toEqual({ key: 'test', value: 'example' }); @@ -72,10 +72,10 @@ describe('filter manager utilities', () => { // @ts-ignore const filter: Filter = { test: 'example' }; - mapping.resolves({ key: 'test', value: 'example' }); + mapping.returns({ key: 'test', value: 'example' }); const chain = generateMappingChain(mapping, next); - const result = await chain(filter); + const result = chain(filter); sinon.assert.calledOnce(mapping); expect(mapping.args[0][0]).toEqual({ test: 'example' }); @@ -86,29 +86,31 @@ describe('filter manager utilities', () => { test('should resolve result for the next function', async () => { const filter: Filter = buildEmptyFilter(true); - mapping.rejects(filter); - next.resolves({ key: 'test', value: 'example' }); + mapping.throws(filter); + next.returns({ key: 'test', value: 'example' }); const chain = generateMappingChain(mapping, next); - const result = await chain(filter); + const result = chain(filter); sinon.assert.calledOnce(mapping); sinon.assert.calledOnce(next); expect(result).toEqual({ key: 'test', value: 'example' }); }); - test('should reject with an error if no functions match', async done => { + test('should throw an error if no functions match', async done => { const filter: Filter = buildEmptyFilter(true); - mapping.rejects(filter); + mapping.throws(filter); const chain = generateMappingChain(mapping); - chain(filter).catch(err => { + try { + chain(filter); + } catch (err) { expect(err).toBeInstanceOf(Error); expect(err.message).toBe('No mappings have been found for filter.'); done(); - }); + } }); }); }); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/generate_mapping_chain.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/generate_mapping_chain.ts index 38c83f3f4a821..760270edd7170 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/generate_mapping_chain.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/generate_mapping_chain.ts @@ -23,12 +23,14 @@ const noop = () => { }; export const generateMappingChain = (fn: Function, next: Function = noop) => { - return async (filter: Filter) => { - return await fn(filter).catch((result: any) => { + return (filter: Filter) => { + try { + return fn(filter); + } catch (result) { if (result === filter) { return next(filter); } throw result; - }); + } }; }; diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_and_flatten_filters.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_and_flatten_filters.test.ts index c9fcc745d48e3..fce2aa0373ebe 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_and_flatten_filters.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_and_flatten_filters.test.ts @@ -19,16 +19,16 @@ import { Filter } from '@kbn/es-query'; import { mapAndFlattenFilters } from './map_and_flatten_filters'; -import { StubIndexPatterns } from '../test_helpers/stub_index_pattern'; -import { IndexPatterns } from '../../../index_patterns'; describe('filter manager utilities', () => { describe('mapAndFlattenFilters()', () => { - let mockIndexPatterns: unknown; let filters: unknown; + function getDisplayName(filter: Filter) { + return typeof filter.meta.value === 'function' ? filter.meta.value() : filter.meta.value; + } + beforeEach(() => { - mockIndexPatterns = new StubIndexPatterns(); filters = [ null, [ @@ -44,11 +44,8 @@ describe('filter manager utilities', () => { ]; }); - test('should map and flatten the filters', async () => { - const results = await mapAndFlattenFilters( - mockIndexPatterns as IndexPatterns, - filters as Filter[] - ); + test('should map and flatten the filters', () => { + const results = mapAndFlattenFilters(filters as Filter[]); expect(results).toHaveLength(5); expect(results[0]).toHaveProperty('meta'); @@ -63,9 +60,11 @@ describe('filter manager utilities', () => { expect(results[2].meta).toHaveProperty('key', 'query'); expect(results[2].meta).toHaveProperty('value', 'foo:bar'); expect(results[3].meta).toHaveProperty('key', 'bytes'); - expect(results[3].meta).toHaveProperty('value', '1024 to 2048'); + expect(results[3].meta).toHaveProperty('value'); + expect(getDisplayName(results[3])).toBe('1024 to 2048'); expect(results[4].meta).toHaveProperty('key', '_type'); - expect(results[4].meta).toHaveProperty('value', 'apache'); + expect(results[4].meta).toHaveProperty('value'); + expect(getDisplayName(results[4])).toBe('apache'); }); }); }); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_and_flatten_filters.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_and_flatten_filters.ts index 1481295896187..b350c3957b142 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_and_flatten_filters.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_and_flatten_filters.ts @@ -20,10 +20,7 @@ import { compact, flatten } from 'lodash'; import { Filter } from '@kbn/es-query'; import { mapFilter } from './map_filter'; -import { IndexPatterns } from '../../../index_patterns'; -export const mapAndFlattenFilters = (indexPatterns: IndexPatterns, filters: Filter[]) => { - const promises = compact(flatten(filters)).map((item: Filter) => mapFilter(indexPatterns, item)); - - return Promise.all(promises); +export const mapAndFlattenFilters = (filters: Filter[]) => { + return compact(flatten(filters)).map((item: Filter) => mapFilter(item)); }; diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_default.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_default.test.ts index 78f3b40bbbe2c..acb6e89711033 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_default.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_default.test.ts @@ -23,7 +23,7 @@ describe('filter manager utilities', () => { describe('mapDefault()', () => { test('should return the key and value for matching filters', async () => { const filter: CustomFilter = buildQueryFilter({ match_all: {} }, 'index'); - const result = await mapDefault(filter); + const result = mapDefault(filter); expect(result).toHaveProperty('key', 'query'); expect(result).toHaveProperty('value', '{"match_all":{}}'); @@ -33,7 +33,7 @@ describe('filter manager utilities', () => { const filter = buildEmptyFilter(true) as CustomFilter; try { - await mapDefault(filter); + mapDefault(filter); } catch (e) { expect(e).toBe(filter); } diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_default.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_default.ts index bdfce4e753f65..70c191879c22e 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_default.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_default.ts @@ -20,7 +20,7 @@ import { Filter, FILTERS } from '@kbn/es-query'; import { find, keys, get } from 'lodash'; -export const mapDefault = async (filter: Filter) => { +export const mapDefault = (filter: Filter) => { const metaProperty = /(^\$|meta)/; const key = find(keys(filter), item => !item.match(metaProperty)); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_exists.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_exists.test.ts index 670056b68234f..c352d3e2b9a73 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_exists.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_exists.test.ts @@ -24,7 +24,7 @@ describe('filter manager utilities', () => { describe('mapExists()', () => { test('should return the key and value for matching filters', async () => { const filter: ExistsFilter = buildExistsFilter({ name: '_type' }, 'index'); - const result = await mapExists(filter); + const result = mapExists(filter); expect(result).toHaveProperty('key', '_type'); expect(result).toHaveProperty('value', 'exists'); @@ -34,7 +34,7 @@ describe('filter manager utilities', () => { const filter = buildEmptyFilter(true) as ExistsFilter; try { - await mapQueryString(filter); + mapQueryString(filter); } catch (e) { expect(e).toBe(filter); done(); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_exists.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_exists.ts index bf793378342c7..d539219a1ca24 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_exists.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_exists.ts @@ -20,7 +20,7 @@ import { Filter, isExistsFilter, FILTERS } from '@kbn/es-query'; import { get } from 'lodash'; -export const mapExists = async (filter: Filter) => { +export const mapExists = (filter: Filter) => { if (isExistsFilter(filter)) { return { type: FILTERS.EXISTS, diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.test.ts index ca701454481ee..ad1c4457f673e 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.test.ts @@ -19,17 +19,11 @@ import { Filter } from '@kbn/es-query'; import { mapFilter } from './map_filter'; -import { StubIndexPatterns } from '../test_helpers/stub_index_pattern'; -import { IndexPatterns } from '../../../index_patterns'; describe('filter manager utilities', () => { - let indexPatterns: IndexPatterns; - - beforeEach(() => { - const stubIndexPatterns: unknown = new StubIndexPatterns(); - - indexPatterns = stubIndexPatterns as IndexPatterns; - }); + function getDisplayName(filter: Filter) { + return typeof filter.meta.value === 'function' ? filter.meta.value() : filter.meta.value; + } describe('mapFilter()', () => { test('should map query filters', async () => { @@ -37,44 +31,48 @@ describe('filter manager utilities', () => { meta: { index: 'logstash-*' }, query: { match: { _type: { query: 'apache' } } }, }; - const after = await mapFilter(indexPatterns, before as Filter); + const after = mapFilter(before as Filter); expect(after).toHaveProperty('meta'); expect(after.meta).toHaveProperty('key', '_type'); - expect(after.meta).toHaveProperty('value', 'apache'); + expect(after.meta).toHaveProperty('value'); + expect(getDisplayName(after)).toBe('apache'); expect(after.meta).toHaveProperty('disabled', false); expect(after.meta).toHaveProperty('negate', false); }); test('should map exists filters', async () => { const before: any = { meta: { index: 'logstash-*' }, exists: { field: '@timestamp' } }; - const after = await mapFilter(indexPatterns, before as Filter); + const after = mapFilter(before as Filter); expect(after).toHaveProperty('meta'); expect(after.meta).toHaveProperty('key', '@timestamp'); - expect(after.meta).toHaveProperty('value', 'exists'); + expect(after.meta).toHaveProperty('value'); + expect(getDisplayName(after)).toBe('exists'); expect(after.meta).toHaveProperty('disabled', false); expect(after.meta).toHaveProperty('negate', false); }); test('should map missing filters', async () => { const before: any = { meta: { index: 'logstash-*' }, missing: { field: '@timestamp' } }; - const after = await mapFilter(indexPatterns, before as Filter); + const after = mapFilter(before as Filter); expect(after).toHaveProperty('meta'); expect(after.meta).toHaveProperty('key', '@timestamp'); - expect(after.meta).toHaveProperty('value', 'missing'); + expect(after.meta).toHaveProperty('value'); + expect(getDisplayName(after)).toBe('missing'); expect(after.meta).toHaveProperty('disabled', false); expect(after.meta).toHaveProperty('negate', false); }); test('should map json filter', async () => { const before: any = { meta: { index: 'logstash-*' }, query: { match_all: {} } }; - const after = await mapFilter(indexPatterns, before as Filter); + const after = mapFilter(before as Filter); expect(after).toHaveProperty('meta'); expect(after.meta).toHaveProperty('key', 'query'); - expect(after.meta).toHaveProperty('value', '{"match_all":{}}'); + expect(after.meta).toHaveProperty('value'); + expect(getDisplayName(after)).toBe('{"match_all":{}}'); expect(after.meta).toHaveProperty('disabled', false); expect(after.meta).toHaveProperty('negate', false); }); @@ -83,7 +81,7 @@ describe('filter manager utilities', () => { const before: any = { meta: { index: 'logstash-*' } }; try { - await mapFilter(indexPatterns, before as Filter); + mapFilter(before as Filter); } catch (e) { expect(e).toBeInstanceOf(Error); expect(e.message).toBe('No mappings have been found for filter.'); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.ts index f607595915a52..c0d251e647fd1 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_filter.ts @@ -19,7 +19,6 @@ import { Filter } from '@kbn/es-query'; import { reduceRight } from 'lodash'; -import { IndexPatterns } from '../../../index_patterns'; import { mapMatchAll } from './map_match_all'; import { mapPhrase } from './map_phrase'; @@ -33,7 +32,7 @@ import { mapGeoPolygon } from './map_geo_polygon'; import { mapDefault } from './map_default'; import { generateMappingChain } from './generate_mapping_chain'; -export async function mapFilter(indexPatterns: IndexPatterns, filter: Filter) { +export function mapFilter(filter: Filter) { /** Mappers **/ // Each mapper is a simple promise function that test if the mapper can @@ -52,14 +51,14 @@ export async function mapFilter(indexPatterns: IndexPatterns, filter: Filter) { // and add it here. ProTip: These are executed in order listed const mappers = [ mapMatchAll, - mapRange(indexPatterns), - mapPhrase(indexPatterns), + mapRange, + mapPhrase, mapPhrases, mapExists, mapMissing, mapQueryString, - mapGeoBoundingBox(indexPatterns), - mapGeoPolygon(indexPatterns), + mapGeoBoundingBox, + mapGeoPolygon, mapDefault, ]; @@ -74,13 +73,15 @@ export async function mapFilter(indexPatterns: IndexPatterns, filter: Filter) { (memo, map) => generateMappingChain(map, memo), noop ); - const mapped = await mapFn(filter); + + const mapped = mapFn(filter); // Map the filter into an object with the key and value exposed so it's // easier to work with in the template filter.meta = filter.meta || {}; filter.meta.type = mapped.type; filter.meta.key = mapped.key; + // Display value or formatter function. filter.meta.value = mapped.value; filter.meta.params = mapped.params; filter.meta.disabled = Boolean(filter.meta.disabled); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_bounding_box.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_bounding_box.test.ts index 51b38c343d2a9..c3c99e6f6c4a3 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_bounding_box.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_bounding_box.test.ts @@ -18,19 +18,10 @@ */ import { mapGeoBoundingBox } from './map_geo_bounding_box'; -import { StubIndexPatterns } from '../test_helpers/stub_index_pattern'; -import { IndexPatterns } from '../../../index_patterns'; +import { Filter, GeoBoundingBoxFilter } from '@kbn/es-query'; describe('filter manager utilities', () => { describe('mapGeoBoundingBox()', () => { - let mapGeoBoundingBoxFn: Function; - - beforeEach(() => { - const indexPatterns: unknown = new StubIndexPatterns(); - - mapGeoBoundingBoxFn = mapGeoBoundingBox(indexPatterns as IndexPatterns); - }); - test('should return the key and value for matching filters with bounds', async () => { const filter = { meta: { @@ -43,16 +34,20 @@ describe('filter manager utilities', () => { bottom_right: { lat: 15, lon: 20 }, }, }, - }; + } as GeoBoundingBoxFilter; - const result = await mapGeoBoundingBoxFn(filter); + const result = mapGeoBoundingBox(filter); expect(result).toHaveProperty('key', 'point'); expect(result).toHaveProperty('value'); - // remove html entities and non-alphanumerics to get the gist of the value - expect(result.value.replace(/&[a-z]+?;/g, '').replace(/[^a-z0-9]/g, '')).toBe( - 'lat5lon10tolat15lon20' - ); + + if (result.value) { + const displayName = result.value(); + // remove html entities and non-alphanumerics to get the gist of the value + expect(displayName.replace(/&[a-z]+?;/g, '').replace(/[^a-z0-9]/g, '')).toBe( + 'lat5lon10tolat15lon20' + ); + } }); test('should return the key and value even when using ignore_unmapped', async () => { @@ -68,25 +63,29 @@ describe('filter manager utilities', () => { bottom_right: { lat: 15, lon: 20 }, }, }, - }; - const result = await mapGeoBoundingBoxFn(filter); + } as GeoBoundingBoxFilter; + const result = mapGeoBoundingBox(filter); expect(result).toHaveProperty('key', 'point'); expect(result).toHaveProperty('value'); - // remove html entities and non-alphanumerics to get the gist of the value - expect(result.value.replace(/&[a-z]+?;/g, '').replace(/[^a-z0-9]/g, '')).toBe( - 'lat5lon10tolat15lon20' - ); + + if (result.value) { + const displayName = result.value(); + // remove html entities and non-alphanumerics to get the gist of the value + expect(displayName.replace(/&[a-z]+?;/g, '').replace(/[^a-z0-9]/g, '')).toBe( + 'lat5lon10tolat15lon20' + ); + } }); test('should return undefined for none matching', async done => { const filter = { meta: { index: 'logstash-*' }, query: { query_string: { query: 'foo:bar' } }, - }; + } as Filter; try { - await mapGeoBoundingBoxFn(filter); + mapGeoBoundingBox(filter); } catch (e) { expect(e).toBe(filter); done(); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_bounding_box.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_bounding_box.ts index de6b63c287cc4..1f9b8cd842509 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_bounding_box.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_bounding_box.ts @@ -16,58 +16,46 @@ * specific language governing permissions and limitations * under the License. */ -import { get } from 'lodash'; -import { GeoBoundingBoxFilter, Filter, FILTERS, isGeoBoundingBoxFilter } from '@kbn/es-query'; -import { IndexPatterns, IndexPattern } from '../../../index_patterns'; -import { SavedObjectNotFound } from '../../../../../../../plugins/kibana_utils/public'; - -const getFormattedValue = (params: any, key: string, indexPattern?: IndexPattern) => { - const formatter: any = - indexPattern && key && get(indexPattern, ['fields', 'byName', key, 'format']); - - return formatter - ? { - topLeft: formatter.convert(params.top_left), - bottomRight: formatter.convert(params.bottom_right), - } - : { - topLeft: JSON.stringify(params.top_left), - bottomRight: JSON.stringify(params.bottom_right), - }; +import { + GeoBoundingBoxFilter, + Filter, + FILTERS, + isGeoBoundingBoxFilter, + FilterValueFormatter, +} from '@kbn/es-query'; + +const getFormattedValueFn = (params: any) => { + return (formatter?: FilterValueFormatter) => { + const corners = formatter + ? { + topLeft: formatter.convert(params.top_left), + bottomRight: formatter.convert(params.bottom_right), + } + : { + topLeft: JSON.stringify(params.top_left), + bottomRight: JSON.stringify(params.bottom_right), + }; + + return corners.topLeft + ' to ' + corners.bottomRight; + }; }; -const getParams = (filter: GeoBoundingBoxFilter, indexPattern?: IndexPattern) => { +const getParams = (filter: GeoBoundingBoxFilter) => { const key = Object.keys(filter.geo_bounding_box).filter(k => k !== 'ignore_unmapped')[0]; const params = filter.geo_bounding_box[key]; - const { topLeft, bottomRight } = getFormattedValue(params, key, indexPattern); return { key, params, type: FILTERS.GEO_BOUNDING_BOX, - value: topLeft + ' to ' + bottomRight, + value: getFormattedValueFn(params), }; }; -export const mapGeoBoundingBox = (indexPatterns: IndexPatterns) => { - return async (filter: Filter) => { - if (!isGeoBoundingBoxFilter(filter)) { - throw filter; - } +export const mapGeoBoundingBox = (filter: Filter) => { + if (!isGeoBoundingBoxFilter(filter)) { + throw filter; + } - try { - let indexPattern; - - if (filter.meta.index) { - indexPattern = await indexPatterns.get(filter.meta.index); - } - - return getParams(filter, indexPattern); - } catch (error) { - if (error instanceof SavedObjectNotFound) { - return getParams(filter); - } - throw error; - } - }; + return getParams(filter); }; diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_polygon.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_polygon.test.ts index b9427d541e703..ee4f9b295d682 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_polygon.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_polygon.test.ts @@ -17,19 +17,10 @@ * under the License. */ import { mapGeoPolygon } from './map_geo_polygon'; -import { StubIndexPatterns } from '../test_helpers/stub_index_pattern'; -import { IndexPatterns } from '../../../index_patterns'; +import { GeoPolygonFilter, Filter } from '@kbn/es-query'; describe('filter manager utilities', () => { describe('mapGeoPolygon()', () => { - let mapGeoPolygonFn: Function; - - beforeEach(() => { - const indexPatterns: unknown = new StubIndexPatterns(); - - mapGeoPolygonFn = mapGeoPolygon(indexPatterns as IndexPatterns); - }); - test('should return the key and value for matching filters with bounds', async () => { const filter = { meta: { @@ -40,17 +31,20 @@ describe('filter manager utilities', () => { points: [{ lat: 5, lon: 10 }, { lat: 15, lon: 20 }], }, }, - }; + } as GeoPolygonFilter; - const result = await mapGeoPolygonFn(filter); + const result = mapGeoPolygon(filter); expect(result).toHaveProperty('key', 'point'); expect(result).toHaveProperty('value'); - // remove html entities and non-alphanumerics to get the gist of the value - expect(result.value.replace(/&[a-z]+?;/g, '').replace(/[^a-z0-9]/g, '')).toBe( - 'lat5lon10lat15lon20' - ); + if (result.value) { + const displayName = result.value(); + // remove html entities and non-alphanumerics to get the gist of the value + expect(displayName.replace(/&[a-z]+?;/g, '').replace(/[^a-z0-9]/g, '')).toBe( + 'lat5lon10lat15lon20' + ); + } }); test('should return the key and value even when using ignore_unmapped', async () => { @@ -64,26 +58,29 @@ describe('filter manager utilities', () => { points: [{ lat: 5, lon: 10 }, { lat: 15, lon: 20 }], }, }, - }; - const result = await mapGeoPolygonFn(filter); + } as GeoPolygonFilter; + const result = mapGeoPolygon(filter); expect(result).toHaveProperty('key', 'point'); expect(result).toHaveProperty('value'); - // remove html entities and non-alphanumerics to get the gist of the value - expect(result.value.replace(/&[a-z]+?;/g, '').replace(/[^a-z0-9]/g, '')).toBe( - 'lat5lon10lat15lon20' - ); + if (result.value) { + const displayName = result.value(); + // remove html entities and non-alphanumerics to get the gist of the value + expect(displayName.replace(/&[a-z]+?;/g, '').replace(/[^a-z0-9]/g, '')).toBe( + 'lat5lon10lat15lon20' + ); + } }); test('should return undefined for none matching', async done => { const filter = { meta: { index: 'logstash-*' }, query: { query_string: { query: 'foo:bar' } }, - }; + } as Filter; try { - await mapGeoPolygonFn(filter); + mapGeoPolygon(filter); } catch (e) { expect(e).toBe(filter); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_polygon.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_polygon.ts index b49e0c7314228..03ce4130d0c97 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_polygon.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_geo_polygon.ts @@ -16,21 +16,25 @@ * specific language governing permissions and limitations * under the License. */ -import { get } from 'lodash'; -import { GeoPolygonFilter, Filter, FILTERS, isGeoPolygonFilter } from '@kbn/es-query'; -import { SavedObjectNotFound } from '../../../../../../../plugins/kibana_utils/public'; -import { IndexPatterns, IndexPattern } from '../../../index_patterns'; +import { + GeoPolygonFilter, + Filter, + FILTERS, + isGeoPolygonFilter, + FilterValueFormatter, +} from '@kbn/es-query'; const POINTS_SEPARATOR = ', '; -const getFormattedValue = (value: any, key: string, indexPattern?: IndexPattern) => { - const formatter: any = - indexPattern && key && get(indexPattern, ['fields', 'byName', key, 'format']); - - return formatter ? formatter.convert(value) : JSON.stringify(value); +const getFormattedValueFn = (points: string[]) => { + return (formatter?: FilterValueFormatter) => { + return points + .map((point: string) => (formatter ? formatter.convert(point) : JSON.stringify(point))) + .join(POINTS_SEPARATOR); + }; }; -function getParams(filter: GeoPolygonFilter, indexPattern?: IndexPattern) { +function getParams(filter: GeoPolygonFilter) { const key = Object.keys(filter.geo_polygon).filter(k => k !== 'ignore_unmapped')[0]; const params = filter.geo_polygon[key]; @@ -38,31 +42,13 @@ function getParams(filter: GeoPolygonFilter, indexPattern?: IndexPattern) { key, params, type: FILTERS.GEO_POLYGON, - value: (params.points || []) - .map((point: string) => getFormattedValue(point, key, indexPattern)) - .join(POINTS_SEPARATOR), + value: getFormattedValueFn(params.points || []), }; } -export function mapGeoPolygon(indexPatterns: IndexPatterns) { - return async function(filter: Filter) { - if (!isGeoPolygonFilter(filter)) { - throw filter; - } - - try { - let indexPattern; - - if (filter.meta.index) { - indexPattern = await indexPatterns.get(filter.meta.index); - } - - return getParams(filter, indexPattern); - } catch (error) { - if (error instanceof SavedObjectNotFound) { - return getParams(filter); - } - throw error; - } - }; +export function mapGeoPolygon(filter: Filter) { + if (!isGeoPolygonFilter(filter)) { + throw filter; + } + return getParams(filter); } diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_match_all.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_match_all.test.ts index 1139f98f51335..2f0641598a2ce 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_match_all.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_match_all.test.ts @@ -41,7 +41,7 @@ describe('filter_manager/lib', () => { delete filter.match_all; try { - await mapMatchAll(filter); + mapMatchAll(filter); } catch (e) { expect(e).toBe(filter); done(); @@ -51,13 +51,13 @@ describe('filter_manager/lib', () => { describe('when given a match_all filter', () => { test('key is set to meta field', async () => { - const result = await mapMatchAll(filter); + const result = mapMatchAll(filter); expect(result).toHaveProperty('key', filter.meta.field); }); test('value is set to meta formattedValue', async () => { - const result = await mapMatchAll(filter); + const result = mapMatchAll(filter); expect(result).toHaveProperty('value', filter.meta.formattedValue); }); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_match_all.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_match_all.ts index a08b60127ac05..a1387e6dbe457 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_match_all.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_match_all.ts @@ -18,7 +18,7 @@ */ import { Filter, FILTERS, isMatchAllFilter } from '@kbn/es-query'; -export const mapMatchAll = async (filter: Filter) => { +export const mapMatchAll = (filter: Filter) => { if (isMatchAllFilter(filter)) { return { type: FILTERS.MATCH_ALL, diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_missing.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_missing.test.ts index d8f7faa8cd9d9..ca23f25826906 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_missing.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_missing.test.ts @@ -26,7 +26,7 @@ describe('filter manager utilities', () => { missing: { field: '_type' }, ...buildEmptyFilter(true), }; - const result = await mapMissing(filter); + const result = mapMissing(filter); expect(result).toHaveProperty('key', '_type'); expect(result).toHaveProperty('value', 'missing'); @@ -36,7 +36,7 @@ describe('filter manager utilities', () => { const filter = buildEmptyFilter(true) as ExistsFilter; try { - await mapMissing(filter); + mapMissing(filter); } catch (e) { expect(e).toBe(filter); done(); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_missing.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_missing.ts index e398917b7f9be..861a84ed61646 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_missing.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_missing.ts @@ -18,7 +18,7 @@ */ import { Filter, FILTERS, isMissingFilter } from '@kbn/es-query'; -export const mapMissing = async (filter: Filter) => { +export const mapMissing = (filter: Filter) => { if (isMissingFilter(filter)) { return { type: FILTERS.MISSING, diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrase.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrase.test.ts index e3eae169e3607..c95a2529add14 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrase.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrase.test.ts @@ -17,38 +17,33 @@ * under the License. */ import { mapPhrase } from './map_phrase'; -import { StubIndexPatterns } from '../test_helpers/stub_index_pattern'; -import { IndexPatterns } from '../../../index_patterns'; +import { PhraseFilter, Filter } from '@kbn/es-query'; describe('filter manager utilities', () => { describe('mapPhrase()', () => { - let mapPhraseFn: Function; - - beforeEach(() => { - const indexPatterns: unknown = new StubIndexPatterns(); - - mapPhraseFn = mapPhrase(indexPatterns as IndexPatterns); - }); - test('should return the key and value for matching filters', async () => { const filter = { meta: { index: 'logstash-*' }, query: { match: { _type: { query: 'apache', type: 'phrase' } } }, - }; - const result = await mapPhraseFn(filter); + } as PhraseFilter; + const result = mapPhrase(filter); + expect(result).toHaveProperty('value'); expect(result).toHaveProperty('key', '_type'); - expect(result).toHaveProperty('value', 'apache'); + if (result.value) { + const displayName = result.value(); + expect(displayName).toBe('apache'); + } }); test('should return undefined for none matching', async done => { const filter = { meta: { index: 'logstash-*' }, query: { query_string: { query: 'foo:bar' } }, - }; + } as Filter; try { - await mapPhraseFn(filter); + mapPhrase(filter); } catch (e) { expect(e).toBe(filter); done(); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrase.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrase.ts index 3ebe363f46df5..4f6ff9e0d9199 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrase.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrase.ts @@ -24,21 +24,19 @@ import { FILTERS, isPhraseFilter, isScriptedPhraseFilter, + FilterValueFormatter, } from '@kbn/es-query'; -import { SavedObjectNotFound } from '../../../../../../../plugins/kibana_utils/public'; -import { IndexPatterns, IndexPattern } from '../../../index_patterns'; const getScriptedPhraseValue = (filter: PhraseFilter) => get(filter, ['script', 'script', 'params', 'value']); -const getFormattedValue = (value: any, key: string, indexPattern?: IndexPattern) => { - const formatter: any = - indexPattern && key && get(indexPattern, ['fields', 'byName', key, 'format']); - - return formatter ? formatter.convert(value) : value; +const getFormattedValueFn = (value: any) => { + return (formatter?: FilterValueFormatter) => { + return formatter ? formatter.convert(value) : value; + }; }; -const getParams = (filter: PhraseFilter, indexPattern?: IndexPattern) => { +const getParams = (filter: PhraseFilter) => { const scriptedPhraseValue = getScriptedPhraseValue(filter); const isScriptedFilter = Boolean(scriptedPhraseValue); const key = isScriptedFilter ? filter.meta.field || '' : Object.keys(filter.query.match)[0]; @@ -49,32 +47,17 @@ const getParams = (filter: PhraseFilter, indexPattern?: IndexPattern) => { key, params, type: FILTERS.PHRASE, - value: getFormattedValue(query, key, indexPattern), + value: getFormattedValueFn(query), }; }; export const isMapPhraseFilter = (filter: any): filter is PhraseFilter => isPhraseFilter(filter) || isScriptedPhraseFilter(filter); -export const mapPhrase = (indexPatterns: IndexPatterns) => { - return async (filter: Filter) => { - if (!isMapPhraseFilter(filter)) { - throw filter; - } - - try { - let indexPattern; +export const mapPhrase = (filter: Filter) => { + if (!isMapPhraseFilter(filter)) { + throw filter; + } - if (filter.meta.index) { - indexPattern = await indexPatterns.get(filter.meta.index); - } - - return getParams(filter, indexPattern); - } catch (error) { - if (error instanceof SavedObjectNotFound) { - return getParams(filter); - } - throw error; - } - }; + return getParams(filter); }; diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrases.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrases.ts index 92226b68ef326..c17ff11d49fd4 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrases.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_phrases.ts @@ -19,7 +19,7 @@ import { Filter, isPhrasesFilter } from '@kbn/es-query'; -export const mapPhrases = async (filter: Filter) => { +export const mapPhrases = (filter: Filter) => { if (!isPhrasesFilter(filter)) { throw filter; } diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_query_string.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_query_string.test.ts index ebf9f05ba387b..4b1a5d39c405d 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_query_string.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_query_string.test.ts @@ -27,7 +27,7 @@ describe('filter manager utilities', () => { { query_string: { query: 'foo:bar' } }, 'index' ); - const result = await mapQueryString(filter); + const result = mapQueryString(filter); expect(result).toHaveProperty('key', 'query'); expect(result).toHaveProperty('value', 'foo:bar'); @@ -37,7 +37,7 @@ describe('filter manager utilities', () => { const filter = buildEmptyFilter(true) as QueryStringFilter; try { - await mapQueryString(filter); + mapQueryString(filter); } catch (e) { expect(e).toBe(filter); done(); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_query_string.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_query_string.ts index 14ad52beaf0b3..94da8074edd04 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_query_string.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_query_string.ts @@ -18,7 +18,7 @@ */ import { Filter, FILTERS, isQueryStringFilter } from '@kbn/es-query'; -export const mapQueryString = async (filter: Filter) => { +export const mapQueryString = (filter: Filter) => { if (isQueryStringFilter(filter)) { return { type: FILTERS.QUERY_STRING, diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_range.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_range.test.ts index 4f81572183005..12d2919e2d47b 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_range.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_range.test.ts @@ -18,43 +18,48 @@ */ import { mapRange } from './map_range'; -import { StubIndexPatterns } from '../test_helpers/stub_index_pattern'; -import { IndexPatterns } from '../../../index_patterns'; +import { RangeFilter, Filter, FilterMeta } from '@kbn/es-query'; describe('filter manager utilities', () => { describe('mapRange()', () => { - let mapRangeFn: Function; - - beforeEach(() => { - const indexPatterns: unknown = new StubIndexPatterns(); - - mapRangeFn = mapRange(indexPatterns as IndexPatterns); - }); - test('should return the key and value for matching filters with gt/lt', async () => { - const filter = { meta: { index: 'logstash-*' }, range: { bytes: { lt: 2048, gt: 1024 } } }; - const result = await mapRangeFn(filter); + const filter = { + meta: { index: 'logstash-*' } as FilterMeta, + range: { bytes: { lt: 2048, gt: 1024 } }, + } as RangeFilter; + const result = mapRange(filter); expect(result).toHaveProperty('key', 'bytes'); - expect(result).toHaveProperty('value', '1024 to 2048'); + expect(result).toHaveProperty('value'); + if (result.value) { + const displayName = result.value(); + expect(displayName).toBe('1024 to 2048'); + } }); test('should return the key and value for matching filters with gte/lte', async () => { - const filter = { meta: { index: 'logstash-*' }, range: { bytes: { lte: 2048, gte: 1024 } } }; - const result = await mapRangeFn(filter); + const filter = { + meta: { index: 'logstash-*' } as FilterMeta, + range: { bytes: { lte: 2048, gte: 1024 } }, + } as RangeFilter; + const result = mapRange(filter); expect(result).toHaveProperty('key', 'bytes'); - expect(result).toHaveProperty('value', '1024 to 2048'); + expect(result).toHaveProperty('value'); + if (result.value) { + const displayName = result.value(); + expect(displayName).toBe('1024 to 2048'); + } }); test('should return undefined for none matching', async done => { const filter = { meta: { index: 'logstash-*' }, query: { query_string: { query: 'foo:bar' } }, - }; + } as Filter; try { - await mapRangeFn(filter); + mapRange(filter); } catch (e) { expect(e).toBe(filter); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_range.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_range.ts index 7b85adacce1d0..76f9d3621e171 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_range.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/map_range.ts @@ -17,15 +17,31 @@ * under the License. */ -import { Filter, RangeFilter, FILTERS, isRangeFilter, isScriptedRangeFilter } from '@kbn/es-query'; +import { + Filter, + RangeFilter, + FILTERS, + isRangeFilter, + isScriptedRangeFilter, + FilterValueFormatter, +} from '@kbn/es-query'; import { get, has } from 'lodash'; -import { SavedObjectNotFound } from '../../../../../../../plugins/kibana_utils/public'; -import { IndexPatterns, IndexPattern, Field } from '../../../index_patterns'; + +const getFormattedValueFn = (left: any, right: any) => { + return (formatter?: FilterValueFormatter) => { + let displayValue = `${left} to ${right}`; + if (formatter) { + const convert = formatter.getConverterFor('text'); + displayValue = `${convert(left)} to ${convert(right)}`; + } + return displayValue; + }; +}; const getFirstRangeKey = (filter: RangeFilter) => filter.range && Object.keys(filter.range)[0]; const getRangeByKey = (filter: RangeFilter, key: string) => get(filter, ['range', key]); -function getParams(filter: RangeFilter, indexPattern?: IndexPattern) { +function getParams(filter: RangeFilter) { const isScriptedRange = isScriptedRangeFilter(filter); const key: string = (isScriptedRange ? filter.meta.field : getFirstRangeKey(filter)) || ''; const params: any = isScriptedRange @@ -38,16 +54,7 @@ function getParams(filter: RangeFilter, indexPattern?: IndexPattern) { let right = has(params, 'lte') ? params.lte : params.lt; if (right == null) right = Infinity; - let value = `${left} to ${right}`; - - // Sometimes a filter will end up with an invalid index param. This could happen for a lot of reasons, - // for example a user might manually edit the url or the index pattern's ID might change due to - // external factors e.g. a reindex. We only need the index in order to grab the field formatter, so we fallback - // on displaying the raw value if the index is invalid. - if (key && indexPattern && indexPattern.fields.getByName(key)) { - const convert = (indexPattern.fields.getByName(key) as Field).format.getConverterFor('text'); - value = `${convert(left)} to ${convert(right)}`; - } + const value = getFormattedValueFn(left, right); return { type: FILTERS.RANGE, key, value, params }; } @@ -55,25 +62,10 @@ function getParams(filter: RangeFilter, indexPattern?: IndexPattern) { export const isMapRangeFilter = (filter: any): filter is RangeFilter => isRangeFilter(filter) || isScriptedRangeFilter(filter); -export const mapRange = (indexPatterns: IndexPatterns) => { - return async (filter: Filter) => { - if (!isMapRangeFilter(filter)) { - throw filter; - } - - try { - let indexPattern; - - if (filter.meta.index) { - indexPattern = await indexPatterns.get(filter.meta.index); - } +export const mapRange = (filter: Filter) => { + if (!isMapRangeFilter(filter)) { + throw filter; + } - return getParams(filter, indexPattern); - } catch (error) { - if (error instanceof SavedObjectNotFound) { - return getParams(filter); - } - throw error; - } - }; + return getParams(filter); }; diff --git a/src/legacy/core_plugins/data/public/filter/filter_service.mock.ts b/src/legacy/core_plugins/data/public/filter/filter_service.mock.ts index 5cacc890cd5cc..94268ef69c49a 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_service.mock.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_service.mock.ts @@ -17,11 +17,17 @@ * under the License. */ -import { FilterService, FilterStart } from '.'; +import { FilterService, FilterStart, FilterSetup } from '.'; type FilterServiceClientContract = PublicMethodsOf; -const createSetupContract = () => {}; +const createSetupContractMock = () => { + const setupContract: jest.Mocked = { + filterManager: jest.fn() as any, + }; + + return setupContract; +}; const createStartContractMock = () => { const startContract: jest.Mocked = { @@ -38,13 +44,13 @@ const createMock = () => { stop: jest.fn(), }; - mocked.setup.mockReturnValue(createSetupContract()); + mocked.setup.mockReturnValue(createSetupContractMock()); mocked.start.mockReturnValue(createStartContractMock()); return mocked; }; export const filterServiceMock = { create: createMock, - createSetupContract, + createSetupContract: createSetupContractMock, createStartContract: createStartContractMock, }; diff --git a/src/legacy/core_plugins/data/public/filter/filter_service.ts b/src/legacy/core_plugins/data/public/filter/filter_service.ts index fbeab1233533e..0c46259ef0e00 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_service.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_service.ts @@ -18,7 +18,6 @@ */ import { UiSettingsClientContract } from 'src/core/public'; -import { IndexPatterns } from '../index_patterns'; import { FilterManager } from './filter_manager'; /** @@ -27,18 +26,23 @@ import { FilterManager } from './filter_manager'; */ export interface FilterServiceDependencies { - indexPatterns: IndexPatterns; uiSettings: UiSettingsClientContract; } export class FilterService { - public setup() { - // Filter service requires index patterns, which are only available in `start` + filterManager!: FilterManager; + + public setup({ uiSettings }: FilterServiceDependencies) { + this.filterManager = new FilterManager(uiSettings); + + return { + filterManager: this.filterManager, + }; } - public start({ indexPatterns, uiSettings }: FilterServiceDependencies) { + public start() { return { - filterManager: new FilterManager(indexPatterns, uiSettings), + filterManager: this.filterManager, }; } @@ -48,4 +52,5 @@ export class FilterService { } /** @public */ +export type FilterSetup = ReturnType; export type FilterStart = ReturnType; diff --git a/src/legacy/core_plugins/data/public/filter/index.tsx b/src/legacy/core_plugins/data/public/filter/index.tsx index 3ada13ba2ceea..cda7350ecadef 100644 --- a/src/legacy/core_plugins/data/public/filter/index.tsx +++ b/src/legacy/core_plugins/data/public/filter/index.tsx @@ -17,7 +17,7 @@ * under the License. */ -export { FilterService, FilterStart } from './filter_service'; +export * from './filter_service'; export { FilterBar } from './filter_bar'; diff --git a/src/legacy/core_plugins/data/public/index_patterns/components/index_pattern_select.tsx b/src/legacy/core_plugins/data/public/index_patterns/components/index_pattern_select.tsx index c6f3a6e35bc43..77692d7bcaa0d 100644 --- a/src/legacy/core_plugins/data/public/index_patterns/components/index_pattern_select.tsx +++ b/src/legacy/core_plugins/data/public/index_patterns/components/index_pattern_select.tsx @@ -22,6 +22,7 @@ import React, { Component } from 'react'; import { EuiComboBox } from '@elastic/eui'; import { SavedObjectsClientContract, SimpleSavedObject } from '../../../../../../core/public'; +import { getIndexPatternTitle } from '../utils'; interface IndexPatternSelectProps { onChange: (opt: any) => void; @@ -54,17 +55,6 @@ const getIndexPatterns = async ( return resp.savedObjects; }; -const getIndexPatternTitle = async ( - client: SavedObjectsClientContract, - indexPatternId: string -): Promise> => { - const savedObject = (await client.get('index-pattern', indexPatternId)) as SimpleSavedObject; - if (savedObject.error) { - throw new Error(`Unable to get index-pattern title: ${savedObject.error.message}`); - } - return savedObject.attributes.title; -}; - // Takes in stateful runtime dependencies and pre-wires them to the component export function createIndexPatternSelect(savedObjectsClient: SavedObjectsClientContract) { return (props: Omit) => ( diff --git a/src/legacy/core_plugins/data/public/index_patterns/index_patterns/index_pattern.ts b/src/legacy/core_plugins/data/public/index_patterns/index_patterns/index_pattern.ts index a5e02e43adf56..35ec4bad68e14 100644 --- a/src/legacy/core_plugins/data/public/index_patterns/index_patterns/index_pattern.ts +++ b/src/legacy/core_plugins/data/public/index_patterns/index_patterns/index_pattern.ts @@ -23,10 +23,10 @@ import { i18n } from '@kbn/i18n'; import { fieldFormats } from 'ui/registry/field_formats'; // @ts-ignore import { expandShorthand } from 'ui/utils/mapping_setup'; -import { findObjectByTitle } from 'ui/saved_objects'; + import { NotificationsSetup, SavedObjectsClientContract } from 'src/core/public'; import { SavedObjectNotFound, DuplicateField } from '../../../../../../plugins/kibana_utils/public'; - +import { findIndexPatternByTitle } from '../utils'; import { IndexPatternMissingIndices } from '../errors'; import { Field, FieldList, FieldType, FieldListInterface } from '../fields'; import { createFieldsFetcher } from './_fields_fetcher'; @@ -387,9 +387,8 @@ export class IndexPattern implements StaticIndexPattern { return response.id; }; - const potentialDuplicateByTitle = await findObjectByTitle( + const potentialDuplicateByTitle = await findIndexPatternByTitle( this.savedObjectsClient, - type, this.title ); // If there is potentially duplicate title, just create it diff --git a/src/legacy/core_plugins/data/public/index_patterns/index_patterns/index_patterns.test.ts b/src/legacy/core_plugins/data/public/index_patterns/index_patterns/index_patterns.test.ts index d2deaa92e2b40..f573f33ef7a52 100644 --- a/src/legacy/core_plugins/data/public/index_patterns/index_patterns/index_patterns.test.ts +++ b/src/legacy/core_plugins/data/public/index_patterns/index_patterns/index_patterns.test.ts @@ -36,12 +36,6 @@ jest.mock('ui/registry/field_formats', () => ({ }, })); -jest.mock('ui/notify', () => ({ - toastNotifications: { - addDanger: jest.fn(), - }, -})); - jest.mock('./index_pattern', () => { class IndexPattern { init = async () => { diff --git a/src/legacy/core_plugins/data/public/index_patterns/index_patterns_service.ts b/src/legacy/core_plugins/data/public/index_patterns/index_patterns_service.ts index 17a0f45865d11..562dcb248edae 100644 --- a/src/legacy/core_plugins/data/public/index_patterns/index_patterns_service.ts +++ b/src/legacy/core_plugins/data/public/index_patterns/index_patterns_service.ts @@ -108,3 +108,6 @@ export type IndexPatternsStart = ReturnType; /** @public */ export { IndexPattern, IndexPatterns, StaticIndexPattern, Field, FieldType, FieldListInterface }; + +/** @public */ +export { getIndexPatternTitle, findIndexPatternByTitle } from './utils'; diff --git a/src/legacy/core_plugins/data/public/index_patterns/utils.ts b/src/legacy/core_plugins/data/public/index_patterns/utils.ts index 237b8386e7561..62f5ddbe9e2b0 100644 --- a/src/legacy/core_plugins/data/public/index_patterns/utils.ts +++ b/src/legacy/core_plugins/data/public/index_patterns/utils.ts @@ -17,12 +17,14 @@ * under the License. */ -import { get } from 'lodash'; +import { find, get } from 'lodash'; import { Field, FieldType } from './fields'; import { StaticIndexPattern } from './index_patterns'; import { getFilterableKbnTypeNames } from '../../../../../plugins/data/public'; +import { SavedObjectsClientContract, SimpleSavedObject } from '../../../../../core/public'; + export const ILLEGAL_CHARACTERS = 'ILLEGAL_CHARACTERS'; export const CONTAINS_SPACES = 'CONTAINS_SPACES'; export const INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE = ['\\', '/', '?', '"', '<', '>', '|']; @@ -44,6 +46,48 @@ function findIllegalCharacters(indexPattern: string): string[] { return illegalCharacters; } +/** + * Returns an object matching a given title + * + * @param client {SavedObjectsClientContract} + * @param title {string} + * @returns {Promise} + */ +export async function findIndexPatternByTitle( + client: SavedObjectsClientContract, + title: string +): Promise | void> { + if (!title) { + return Promise.resolve(); + } + + const { savedObjects } = await client.find({ + type: 'index-pattern', + perPage: 10, + search: `"${title}"`, + searchFields: ['title'], + fields: ['title'], + }); + + return find( + savedObjects, + (obj: SimpleSavedObject) => obj.get('title').toLowerCase() === title.toLowerCase() + ); +} + +export async function getIndexPatternTitle( + client: SavedObjectsClientContract, + indexPatternId: string +): Promise> { + const savedObject = (await client.get('index-pattern', indexPatternId)) as SimpleSavedObject; + + if (savedObject.error) { + throw new Error(`Unable to get index-pattern title: ${savedObject.error.message}`); + } + + return savedObject.attributes.title; +} + function indexPatternContainsSpaces(indexPattern: string): boolean { return indexPattern.includes(' '); } diff --git a/src/legacy/core_plugins/data/public/plugin.ts b/src/legacy/core_plugins/data/public/plugin.ts index 6f9660d986e79..c25d6742db8da 100644 --- a/src/legacy/core_plugins/data/public/plugin.ts +++ b/src/legacy/core_plugins/data/public/plugin.ts @@ -20,7 +20,7 @@ import { CoreSetup, CoreStart, Plugin } from 'kibana/public'; import { SearchService, SearchStart, createSearchBar, StatetfulSearchBarProps } from './search'; import { QueryService, QuerySetup } from './query'; -import { FilterService, FilterStart } from './filter'; +import { FilterService, FilterSetup, FilterStart } from './filter'; import { TimefilterService, TimefilterSetup } from './timefilter'; import { IndexPatternsService, IndexPatternsSetup, IndexPatternsStart } from './index_patterns'; import { @@ -52,6 +52,7 @@ export interface DataSetup { query: QuerySetup; timefilter: TimefilterSetup; indexPatterns: IndexPatternsSetup; + filter: FilterSetup; } /** @@ -100,10 +101,14 @@ export class DataPlugin uiSettings, store: __LEGACY.storage, }); + const filterService = this.filter.setup({ + uiSettings, + }); this.setupApi = { indexPatterns: this.indexPatterns.setup(), query: this.query.setup(), timefilter: timefilterService, + filter: filterService, }; return this.setupApi; @@ -119,23 +124,17 @@ export class DataPlugin notifications, }); - const filterService = this.filter.start({ - uiSettings, - indexPatterns: indexPatternsService.indexPatterns, - }); - const SearchBar = createSearchBar({ core, data, store: __LEGACY.storage, timefilter: this.setupApi.timefilter, - filterManager: filterService.filterManager, + filterManager: this.setupApi.filter.filterManager, }); return { ...this.setupApi!, indexPatterns: indexPatternsService, - filter: filterService, search: this.search.start(savedObjects.client), ui: { SearchBar, diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx index 3683fb7c2c16e..8c7441088bab0 100644 --- a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx @@ -17,6 +17,7 @@ * under the License. */ +import { compact } from 'lodash'; import { Filter } from '@kbn/es-query'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import classNames from 'classnames'; @@ -195,7 +196,12 @@ class SearchBarUI extends Component { } private shouldRenderFilterBar() { - return this.props.showFilterBar && this.props.filters && this.props.indexPatterns; + return ( + this.props.showFilterBar && + this.props.filters && + this.props.indexPatterns && + compact(this.props.indexPatterns).length > 0 + ); } public setFilterBarHeight = () => { diff --git a/src/legacy/core_plugins/data/public/shim/apply_filter_directive.html b/src/legacy/core_plugins/data/public/shim/apply_filter_directive.html index ed7a5d70a2b80..0b02fcefcdbd5 100644 --- a/src/legacy/core_plugins/data/public/shim/apply_filter_directive.html +++ b/src/legacy/core_plugins/data/public/shim/apply_filter_directive.html @@ -4,4 +4,5 @@ filters="state.filters" on-cancel="onCancel" on-submit="onSubmit" + index-patterns="indexPatterns" > diff --git a/src/legacy/core_plugins/data/public/shim/legacy_module.ts b/src/legacy/core_plugins/data/public/shim/legacy_module.ts index 32b88720a4579..9ce35e6d2fa9e 100644 --- a/src/legacy/core_plugins/data/public/shim/legacy_module.ts +++ b/src/legacy/core_plugins/data/public/shim/legacy_module.ts @@ -79,7 +79,7 @@ export const initLegacyModule = once((): void => { .directive('applyFiltersPopoverComponent', (reactDirective: any) => reactDirective(wrapInI18nContext(ApplyFiltersPopover)) ) - .directive('applyFiltersPopover', (indexPatterns: IndexPatterns) => { + .directive('applyFiltersPopover', () => { return { template, restrict: 'E', @@ -87,6 +87,7 @@ export const initLegacyModule = once((): void => { filters: '=', onCancel: '=', onSubmit: '=', + indexPatterns: '=', }, link($scope: any) { $scope.state = {}; @@ -94,8 +95,8 @@ export const initLegacyModule = once((): void => { // Each time the new filters change we want to rebuild (not just re-render) the "apply filters" // popover, because it has to reset its state whenever the new filters change. Setting a `key` // property on the component accomplishes this due to how React handles the `key` property. - $scope.$watch('filters', async (filters: any) => { - const mappedFilters: Filter[] = await mapAndFlattenFilters(indexPatterns, filters); + $scope.$watch('filters', (filters: any) => { + const mappedFilters: Filter[] = mapAndFlattenFilters(filters); $scope.state = { filters: mappedFilters, key: Date.now(), @@ -107,7 +108,7 @@ export const initLegacyModule = once((): void => { const module = uiModules.get('kibana/index_patterns'); let _service: any; - module.service('indexPatterns', function(chrome: any) { + module.service('indexPatterns', function() { if (!_service) _service = new IndexPatterns( npStart.core.uiSettings, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html index 39db357a69321..68c8131fa1a7b 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html @@ -46,6 +46,7 @@ filters="appState.$newFilters" on-cancel="onCancelApplyFilters" on-submit="onApplyFilters" + index-patterns="indexPatterns" >

diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx index de32d060b6cdb..60004374eb33e 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx @@ -258,7 +258,7 @@ export class DashboardAppController { updateIndexPatterns(dashboardContainer); }); - inputSubscription = dashboardContainer.getInput$().subscribe(async () => { + inputSubscription = dashboardContainer.getInput$().subscribe(() => { let dirty = false; // This has to be first because handleDashboardContainerChanges causes @@ -266,7 +266,7 @@ export class DashboardAppController { // Add filters modifies the object passed to it, hence the clone deep. if (!_.isEqual(container.getInput().filters, queryFilter.getFilters())) { - await queryFilter.addFilters(_.cloneDeep(container.getInput().filters)); + queryFilter.addFilters(_.cloneDeep(container.getInput().filters)); dashboardStateManager.applyFilters($scope.model.query, container.getInput().filters); dirty = true; @@ -453,7 +453,6 @@ export class DashboardAppController { $scope.onClearSavedQuery = () => { delete $scope.savedQuery; dashboardStateManager.setSavedQueryId(undefined); - queryFilter.removeAll(); dashboardStateManager.applyFilters( { query: '', @@ -462,10 +461,12 @@ export class DashboardAppController { }, [] ); + // Making this method sync broke the updates. + // Temporary fix, until we fix the complex state in this file. + setTimeout(queryFilter.removeAll, 0); }; const updateStateFromSavedQuery = (savedQuery: SavedQuery) => { - queryFilter.setFilters(savedQuery.attributes.filters || []); dashboardStateManager.applyFilters( savedQuery.attributes.query, savedQuery.attributes.filters || [] @@ -479,6 +480,11 @@ export class DashboardAppController { timefilter.setRefreshInterval(savedQuery.attributes.timefilter.refreshInterval); } } + // Making this method sync broke the updates. + // Temporary fix, until we fix the complex state in this file. + setTimeout(() => { + queryFilter.setFilters(savedQuery.attributes.filters || []); + }, 0); }; $scope.$watch('savedQuery', (newSavedQuery: SavedQuery) => { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/lib/filter_utils.ts b/src/legacy/core_plugins/kibana/public/dashboard/lib/filter_utils.ts index 3b6b99dcb6d25..1fd50081c58bd 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/lib/filter_utils.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/lib/filter_utils.ts @@ -66,6 +66,14 @@ export class FilterUtils { * @returns {Array.} */ public static cleanFiltersForComparison(filters: Filter[]) { - return _.map(filters, filter => _.omit(filter, ['$$hashKey', '$state'])); + return _.map(filters, filter => { + const f: Partial = _.omit(filter, ['$$hashKey', '$state']); + if (f.meta) { + // f.meta.value is the value displayed in the filter bar. + // It may also be loaded differently and shouldn't be used in this comparison. + return _.omit(f.meta, ['value']); + } + return f; + }); } } diff --git a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.html b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.html index aa59b2d0a81c4..50e921e14973b 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.html +++ b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.html @@ -77,6 +77,7 @@ filters="state.$newFilters" on-cancel="onCancelApplyFilters" on-submit="onApplyFilters" + index-patterns="indexPatterns" >
class VegaVisualization { constructor(el, vis) { this.savedObjectsClient = chrome.getSavedObjectsClient(); @@ -40,7 +41,7 @@ export const createVegaVisualization = ({ serviceSettings }) => class VegaVisual async findIndex(index) { let idxObj; if (index) { - idxObj = await findObjectByTitle(this.savedObjectsClient, 'index-pattern', index); + idxObj = await findIndexPatternByTitle(this.savedObjectsClient, index); if (!idxObj) { throw new Error(i18n.translate('visTypeVega.visualization.indexNotFoundErrorMessage', { defaultMessage: 'Index {index} not found', diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index d12ac08e273c0..f5771c6b86d9a 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -24,9 +24,11 @@ import { SavedObjectsClientProviderOptions, CoreSetup } from 'src/core/server'; import { ConfigService, ElasticsearchServiceSetup, + EnvironmentMode, LoggerFactory, SavedObjectsClientContract, SavedObjectsLegacyService, + PackageInfo, } from '../../core/server'; import { LegacyServiceSetupDeps, LegacyServiceStartDeps } from '../../core/server/'; @@ -102,6 +104,10 @@ type KbnMixinFunc = (kbnServer: KbnServer, server: Server, config: any) => Promi // eslint-disable-next-line import/no-default-export export default class KbnServer { public readonly newPlatform: { + env: { + mode: Readonly; + packageInfo: Readonly; + }; coreContext: { logger: LoggerFactory; }; diff --git a/src/legacy/server/kbn_server.js b/src/legacy/server/kbn_server.js index 56539f409a34c..f7ed56b10c267 100644 --- a/src/legacy/server/kbn_server.js +++ b/src/legacy/server/kbn_server.js @@ -57,10 +57,14 @@ export default class KbnServer { this.settings = settings || {}; this.config = config; - const { setupDeps, startDeps, handledConfigPaths, logger, __internals } = core; + const { setupDeps, startDeps, handledConfigPaths, logger, __internals, env } = core; this.server = __internals.hapiServer; this.newPlatform = { + env: { + mode: env.mode, + packageInfo: env.packageInfo, + }, __internals, coreContext: { logger, diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index 68cfd756acf6b..883358e0d3c9a 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -237,6 +237,7 @@ export function uiRenderMixin(kbnServer, server, config) { buildNumber: config.get('pkg.buildNum'), branch: config.get('pkg.branch'), basePath, + env: kbnServer.newPlatform.env, legacyMode: app.getId() !== 'core', i18n: { translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, diff --git a/src/optimize/base_optimizer.js b/src/optimize/base_optimizer.js index f88e05ab91dde..0e7d03498c0aa 100644 --- a/src/optimize/base_optimizer.js +++ b/src/optimize/base_optimizer.js @@ -479,8 +479,10 @@ export default class BaseOptimizer { optimization: { minimizer: [ new TerserPlugin({ - parallel: true, + parallel: this.getThreadLoaderPoolConfig().workers, sourceMap: false, + cache: false, + extractComments: false, terserOptions: { compress: false, mangle: false diff --git a/src/optimize/dynamic_dll_plugin/dll_config_model.js b/src/optimize/dynamic_dll_plugin/dll_config_model.js index 46aa472b5ae56..cb1f9be9b16c9 100644 --- a/src/optimize/dynamic_dll_plugin/dll_config_model.js +++ b/src/optimize/dynamic_dll_plugin/dll_config_model.js @@ -224,6 +224,8 @@ function optimized(config) { // the parallel processes on terser parallel: config.threadLoaderPoolConfig.workers, sourceMap: false, + cache: false, + extractComments: false, terserOptions: { compress: { // The following is required for dead-code the removal diff --git a/src/plugins/data/public/search/es_search/es_search_service.test.ts b/src/plugins/data/public/search/es_search/es_search_service.test.ts index d1069cd6815fe..d756b54a5cce6 100644 --- a/src/plugins/data/public/search/es_search/es_search_service.test.ts +++ b/src/plugins/data/public/search/es_search/es_search_service.test.ts @@ -25,10 +25,9 @@ import { searchSetupMock } from '../mocks'; describe('ES search strategy service', () => { let service: EsSearchService; let mockCoreSetup: MockedKeys; - const opaqueId = Symbol(); beforeEach(() => { - service = new EsSearchService({ opaqueId }); + service = new EsSearchService(coreMock.createPluginInitializerContext()); mockCoreSetup = coreMock.createSetup(); }); diff --git a/src/plugins/data/public/search/es_search/index.test.ts b/src/plugins/data/public/search/es_search/index.test.ts index 7f7a2ed397d43..ecceffc752e9a 100644 --- a/src/plugins/data/public/search/es_search/index.test.ts +++ b/src/plugins/data/public/search/es_search/index.test.ts @@ -16,10 +16,10 @@ * specific language governing permissions and limitations * under the License. */ - +import { coreMock } from '../../../../../../src/core/public/mocks'; import { esSearchService } from '.'; it('es search service is instantiated', () => { - const esSearch = esSearchService({ opaqueId: Symbol() }); + const esSearch = esSearchService(coreMock.createPluginInitializerContext()); expect(esSearch).toBeDefined(); }); diff --git a/src/plugins/data/public/search/search_service.test.ts b/src/plugins/data/public/search/search_service.test.ts index 8102b0e915fb7..69e05e8df48e2 100644 --- a/src/plugins/data/public/search/search_service.test.ts +++ b/src/plugins/data/public/search/search_service.test.ts @@ -25,9 +25,9 @@ import { CoreSetup } from '../../../../core/public'; describe('Search service', () => { let searchService: SearchService; let mockCoreSetup: MockedKeys; - const opaqueId = Symbol(); + beforeEach(() => { - searchService = new SearchService({ opaqueId }); + searchService = new SearchService(coreMock.createPluginInitializerContext()); mockCoreSetup = coreMock.createSetup(); }); diff --git a/src/plugins/data/server/search/es_search/es_search_service.test.ts b/src/plugins/data/server/search/es_search/es_search_service.test.ts index faf9487159c15..0b274c62958a9 100644 --- a/src/plugins/data/server/search/es_search/es_search_service.test.ts +++ b/src/plugins/data/server/search/es_search/es_search_service.test.ts @@ -19,31 +19,13 @@ import { coreMock } from '../../../../../core/server/mocks'; import { EsSearchService } from './es_search_service'; -import { PluginInitializerContext } from '../../../../../core/server'; import { searchSetupMock } from '../mocks'; describe('ES search strategy service', () => { let service: EsSearchService; const mockCoreSetup = coreMock.createSetup(); - const opaqueId = Symbol(); - const context: PluginInitializerContext = { - opaqueId, - config: { - createIfExists: jest.fn(), - create: jest.fn(), - }, - env: { - mode: { - dev: false, - name: 'development', - prod: false, - }, - }, - logger: { - get: jest.fn(), - }, - }; + const context = coreMock.createPluginInitializerContext(); beforeEach(() => { service = new EsSearchService(context); diff --git a/src/core/server/types.ts~master b/src/plugins/es_ui_shared/public/components/section_loading/index.ts similarity index 83% rename from src/core/server/types.ts~master rename to src/plugins/es_ui_shared/public/components/section_loading/index.ts index 9b55da17a40a8..6e10fe9a89cd1 100644 --- a/src/core/server/types.ts~master +++ b/src/plugins/es_ui_shared/public/components/section_loading/index.ts @@ -17,6 +17,4 @@ * under the License. */ -/** This module is intended for consumption by public to avoid import issues with server-side code */ - -export { PluginOpaqueId } from './plugins/types'; +export { SectionLoading } from './section_loading'; diff --git a/src/plugins/es_ui_shared/public/components/section_loading/section_loading.tsx b/src/plugins/es_ui_shared/public/components/section_loading/section_loading.tsx new file mode 100644 index 0000000000000..41495d605d768 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/section_loading/section_loading.tsx @@ -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 React from 'react'; + +import { + EuiEmptyPrompt, + EuiLoadingSpinner, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, +} from '@elastic/eui'; + +interface Props { + inline?: boolean; + children: React.ReactNode; + [key: string]: any; +} + +export const SectionLoading: React.FunctionComponent = ({ inline, children, ...rest }) => { + if (inline) { + return ( + + + + + + + {children} + + + + ); + } + + return ( + } + body={{children}} + data-test-subj="sectionLoading" + /> + ); +}; diff --git a/test/plugin_functional/plugins/core_plugin_b/public/index.ts b/test/plugin_functional/plugins/core_plugin_b/public/index.ts index db3b5c42a964f..31aa7f81c2ac1 100644 --- a/test/plugin_functional/plugins/core_plugin_b/public/index.ts +++ b/test/plugin_functional/plugins/core_plugin_b/public/index.ts @@ -17,7 +17,7 @@ * under the License. */ -import { PluginInitializer } from 'kibana/public'; +import { PluginInitializer, PluginInitializerContext } from 'kibana/public'; import { CorePluginBDeps, CorePluginBPlugin, @@ -29,4 +29,4 @@ export const plugin: PluginInitializer< CorePluginBPluginSetup, CorePluginBPluginStart, CorePluginBDeps -> = () => new CorePluginBPlugin(); +> = (context: PluginInitializerContext) => new CorePluginBPlugin(context); diff --git a/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx b/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx index 627fd05404b24..56cc1cb4ab425 100644 --- a/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx +++ b/test/plugin_functional/plugins/core_plugin_b/public/plugin.tsx @@ -17,13 +17,14 @@ * under the License. */ -import { Plugin, CoreSetup } from 'kibana/public'; +import { CoreSetup, Plugin, PluginInitializerContext } from 'kibana/public'; import { CorePluginAPluginSetup } from '../../core_plugin_a/public/plugin'; declare global { interface Window { corePluginB?: string; hasAccessToInjectedMetadata?: boolean; + env?: PluginInitializerContext['env']; } } @@ -33,6 +34,9 @@ export interface CorePluginBDeps { export class CorePluginBPlugin implements Plugin { + constructor(pluginContext: PluginInitializerContext) { + window.env = pluginContext.env; + } public setup(core: CoreSetup, deps: CorePluginBDeps) { window.corePluginB = `Plugin A said: ${deps.core_plugin_a.getGreeting()}`; window.hasAccessToInjectedMetadata = 'getInjectedVar' in core.injectedMetadata; diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx index ba9a874bcc5d3..5cfaa1c22f4e5 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx @@ -86,7 +86,7 @@ export class DashboardContainerExample extends React.Component { } public switchViewMode = () => { - this.setState((prevState: State) => { + this.setState<'viewMode'>((prevState: State) => { if (!this.container || isErrorEmbeddable(this.container)) { return prevState; } diff --git a/test/plugin_functional/test_suites/core_plugins/ui_plugins.js b/test/plugin_functional/test_suites/core_plugins/ui_plugins.js index df855f243d403..15a4dcabddbd1 100644 --- a/test/plugin_functional/test_suites/core_plugins/ui_plugins.js +++ b/test/plugin_functional/test_suites/core_plugins/ui_plugins.js @@ -44,5 +44,16 @@ export default function ({ getService, getPageObjects }) { expect(hasAccessToInjectedMetadata).to.equal(true); }); }); + describe('have env data provided', function describeIndexTests() { + before(async () => { + await PageObjects.common.navigateToApp('bar'); + }); + + it('should attach pluginContext to window.corePluginB', async () => { + const envData = await browser.execute('return window.env'); + expect(envData.mode.dev).to.be(true); + expect(envData.packageInfo.version).to.be.a('string'); + }); + }); }); } diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 1eb5bd752baee..872d4ed9c29d1 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -35,7 +35,7 @@ "xpack.security": "legacy/plugins/security", "xpack.server": "legacy/server", "xpack.snapshotRestore": "legacy/plugins/snapshot_restore", - "xpack.spaces": "legacy/plugins/spaces", + "xpack.spaces": ["legacy/plugins/spaces", "plugins/spaces"], "xpack.transform": "legacy/plugins/transform", "xpack.upgradeAssistant": "legacy/plugins/upgrade_assistant", "xpack.uptime": "legacy/plugins/uptime", diff --git a/x-pack/legacy/plugins/actions/server/actions_config.ts b/x-pack/legacy/plugins/actions/server/actions_config.ts index 0d33914920b21..3053c88f1c9ef 100644 --- a/x-pack/legacy/plugins/actions/server/actions_config.ts +++ b/x-pack/legacy/plugins/actions/server/actions_config.ts @@ -61,7 +61,7 @@ function isWhitelistedHostnameInUri(config: ActionsConfigType, uri: string): boo tryCatch(() => new URL(uri)), map(url => url.hostname), mapNullable(hostname => isWhitelisted(config, hostname)), - getOrElse(() => false) + getOrElse(() => false) ); } diff --git a/x-pack/legacy/plugins/actions/server/lib/action_executor.ts b/x-pack/legacy/plugins/actions/server/lib/action_executor.ts index 5df2fe082eb13..aef389262f884 100644 --- a/x-pack/legacy/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/legacy/plugins/actions/server/lib/action_executor.ts @@ -6,7 +6,7 @@ import Hapi from 'hapi'; import { EncryptedSavedObjectsStartContract } from '../shim'; -import { SpacesPlugin as SpacesPluginStartContract } from '../../../spaces'; +import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../../spaces'; import { Logger } from '../../../../../../src/core/server'; import { validateParams, validateConfig, validateSecrets } from './validate_with_schema'; import { diff --git a/x-pack/legacy/plugins/actions/server/shim.ts b/x-pack/legacy/plugins/actions/server/shim.ts index 60873ffed0e8e..c457a40a78b67 100644 --- a/x-pack/legacy/plugins/actions/server/shim.ts +++ b/x-pack/legacy/plugins/actions/server/shim.ts @@ -11,7 +11,7 @@ import { ActionsConfigType } from './types'; import { TaskManager } from '../../task_manager'; import { XPackMainPlugin } from '../../xpack_main/xpack_main'; import KbnServer from '../../../../../src/legacy/server/kbn_server'; -import { SpacesPlugin as SpacesPluginStartContract } from '../../spaces'; +import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../spaces'; import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects'; import { PluginSetupContract as SecurityPlugin } from '../../../../plugins/security/server'; import { diff --git a/x-pack/legacy/plugins/alerting/server/shim.ts b/x-pack/legacy/plugins/alerting/server/shim.ts index 78fb0837d1031..c977fda451df1 100644 --- a/x-pack/legacy/plugins/alerting/server/shim.ts +++ b/x-pack/legacy/plugins/alerting/server/shim.ts @@ -6,7 +6,7 @@ import Hapi from 'hapi'; import { Legacy } from 'kibana'; -import { SpacesPlugin as SpacesPluginStartContract } from '../../spaces'; +import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../spaces'; import { TaskManager } from '../../task_manager'; import { XPackMainPlugin } from '../../xpack_main/xpack_main'; import KbnServer from '../../../../../src/legacy/server/kbn_server'; diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts index e0b9f6e03beab..3c71412da2c89 100644 --- a/x-pack/legacy/plugins/alerting/server/types.ts +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -5,7 +5,7 @@ */ import { AlertInstance } from './lib'; -import { AlertTypeRegistry } from './alert_type_registry'; +import { AlertTypeRegistry as OrigAlertTypeRegistry } from './alert_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { SavedObjectAttributes, SavedObjectsClientContract } from '../../../../../src/core/server'; @@ -95,4 +95,4 @@ export interface AlertingPlugin { start: PluginStartContract; } -export type AlertTypeRegistry = PublicMethodsOf; +export type AlertTypeRegistry = PublicMethodsOf; diff --git a/x-pack/legacy/plugins/apm/common/i18n.ts b/x-pack/legacy/plugins/apm/common/i18n.ts index 18d2d4df7a93b..1e27baa7c28de 100644 --- a/x-pack/legacy/plugins/apm/common/i18n.ts +++ b/x-pack/legacy/plugins/apm/common/i18n.ts @@ -12,3 +12,10 @@ export const NOT_AVAILABLE_LABEL = i18n.translate( defaultMessage: 'N/A' } ); + +export const UNIDENTIFIED_SERVICE_NODES_LABEL = i18n.translate( + 'xpack.apm.serviceNodeNameMissing', + { + defaultMessage: '(Empty)' + } +); diff --git a/x-pack/legacy/plugins/apm/common/projections/metrics.ts b/x-pack/legacy/plugins/apm/common/projections/metrics.ts index 96d7992e2919b..5c9eeb54744d7 100644 --- a/x-pack/legacy/plugins/apm/common/projections/metrics.ts +++ b/x-pack/legacy/plugins/apm/common/projections/metrics.ts @@ -5,8 +5,25 @@ */ import { Setup } from '../../server/lib/helpers/setup_request'; -import { SERVICE_NAME, PROCESSOR_EVENT } from '../elasticsearch_fieldnames'; +import { + SERVICE_NAME, + PROCESSOR_EVENT, + SERVICE_NODE_NAME +} from '../elasticsearch_fieldnames'; import { rangeFilter } from '../../server/lib/helpers/range_filter'; +import { SERVICE_NODE_NAME_MISSING } from '../service_nodes'; + +function getServiceNodeNameFilters(serviceNodeName?: string) { + if (!serviceNodeName) { + return []; + } + + if (serviceNodeName === SERVICE_NODE_NAME_MISSING) { + return [{ bool: { must_not: [{ exists: { field: SERVICE_NODE_NAME } }] } }]; + } + + return [{ term: { [SERVICE_NODE_NAME]: serviceNodeName } }]; +} export function getMetricsProjection({ setup, @@ -19,24 +36,20 @@ export function getMetricsProjection({ }) { const { start, end, uiFiltersES, config } = setup; - const serviceNodeNameFilters = serviceNodeName - ? [{ term: { [SERVICE_NAME]: serviceName } }] - : []; + const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [PROCESSOR_EVENT]: 'metric' } }, + { range: rangeFilter(start, end) }, + ...getServiceNodeNameFilters(serviceNodeName), + ...uiFiltersES + ]; return { index: config.get('apm_oss.metricsIndices'), body: { query: { bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - ...serviceNodeNameFilters, - { term: { [PROCESSOR_EVENT]: 'metric' } }, - { - range: rangeFilter(start, end) - }, - ...uiFiltersES - ] + filter } } } diff --git a/x-pack/legacy/plugins/apm/common/projections/service_nodes.ts b/x-pack/legacy/plugins/apm/common/projections/service_nodes.ts index 95f92fe623a13..10ce75785c3bc 100644 --- a/x-pack/legacy/plugins/apm/common/projections/service_nodes.ts +++ b/x-pack/legacy/plugins/apm/common/projections/service_nodes.ts @@ -5,42 +5,35 @@ */ import { Setup } from '../../server/lib/helpers/setup_request'; -import { - SERVICE_NAME, - SERVICE_NODE_NAME, - PROCESSOR_EVENT -} from '../elasticsearch_fieldnames'; -import { rangeFilter } from '../../server/lib/helpers/range_filter'; +import { SERVICE_NODE_NAME } from '../elasticsearch_fieldnames'; +import { mergeProjection } from './util/merge_projection'; +import { getMetricsProjection } from './metrics'; export function getServiceNodesProjection({ setup, - serviceName + serviceName, + serviceNodeName }: { setup: Setup; serviceName: string; + serviceNodeName?: string; }) { - const { start, end, uiFiltersES, config } = setup; - - return { - index: config.get('apm_oss.metricsIndices'), - body: { - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [PROCESSOR_EVENT]: 'metric' } }, - { range: rangeFilter(start, end) }, - ...uiFiltersES - ] - } - }, - aggs: { - nodes: { - terms: { - field: SERVICE_NODE_NAME + return mergeProjection( + getMetricsProjection({ + setup, + serviceName, + serviceNodeName + }), + { + body: { + aggs: { + nodes: { + terms: { + field: SERVICE_NODE_NAME + } } } } } - }; + ); } diff --git a/x-pack/legacy/plugins/apm/common/service_nodes.ts b/x-pack/legacy/plugins/apm/common/service_nodes.ts new file mode 100644 index 0000000000000..ae20a40af87ac --- /dev/null +++ b/x-pack/legacy/plugins/apm/common/service_nodes.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 const SERVICE_NODE_NAME_MISSING = '_service_node_name_missing_'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx index 3f009a990afa5..41fffc3b847b1 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx @@ -189,7 +189,7 @@ export function TabContent({ }) { const codeLanguage = idx(error, _ => _.service.language.name); const excStackframes = idx(error, _ => _.error.exception[0].stacktrace); - const logStackframes = idx(error, _ => _.error.exception[0].stacktrace); + const logStackframes = idx(error, _ => _.error.log.stacktrace); switch (currentTab.key) { case logStacktraceTab.key: diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index 9a687c1e84287..2667d03ef8dde 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -18,7 +18,7 @@ interface IBucket { // TODO: cleanup duplication of this in distribution/get_distribution.ts (ErrorDistributionAPIResponse) and transactions/distribution/index.ts (TransactionDistributionAPIResponse) interface IDistribution { - totalHits: number; + noHits: boolean; buckets: IBucket[]; bucketSize: number; } @@ -57,9 +57,7 @@ export function ErrorDistribution({ distribution, title }: Props) { distribution.bucketSize ); - const isEmpty = distribution.totalHits === 0; - - if (isEmpty) { + if (distribution.noHits) { return ( List should render empty state 1`] = ` }, ] } - hidePerPageOptions={false} initialPageSize={25} initialSortDirection="desc" initialSortField="latestOccurrenceAt" @@ -284,49 +283,7 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` >
-
-
- -
-
-
+ />
@@ -404,7 +361,6 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` }, ] } - hidePerPageOptions={false} initialPageSize={25} initialSortDirection="desc" initialSortField="latestOccurrenceAt" @@ -1088,49 +1044,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` >
-
-
- -
-
-
+ />
diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx index 96a8ad01bb8a1..37fa6499ff176 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx @@ -159,7 +159,6 @@ const ErrorGroupList: React.FC = props => { initialSortField="latestOccurrenceAt" initialSortDirection="desc" sortItems={false} - hidePerPageOptions={false} /> ); }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx index d579b6dff9c6f..6c95095592fe4 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { Redirect, RouteComponentProps } from 'react-router-dom'; import { npStart } from 'ui/new_platform'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../../common/service_nodes'; import { ErrorGroupDetails } from '../../ErrorGroupDetails'; import { ServiceDetails } from '../../ServiceDetails'; import { TransactionDetails } from '../../TransactionDetails'; @@ -18,6 +19,7 @@ import { AgentConfigurations } from '../../Settings/AgentConfigurations'; import { toQuery } from '../../../shared/Links/url_helpers'; import { ServiceNodeMetrics } from '../../ServiceNodeMetrics'; import { resolveUrlParams } from '../../../../context/UrlParamsContext/resolveUrlParams'; +import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../common/i18n'; const metricsBreadcrumb = i18n.translate('xpack.apm.breadcrumb.metricsTitle', { defaultMessage: 'Metrics' @@ -135,6 +137,11 @@ export const routes: BreadcrumbRoute[] = [ component: () => , breadcrumb: ({ location }) => { const { serviceNodeName } = resolveUrlParams(location, {}); + + if (serviceNodeName === SERVICE_NODE_NAME_MISSING) { + return UNIDENTIFIED_SERVICE_NODES_LABEL; + } + return serviceNodeName || ''; }, name: RouteName.SERVICE_NODE_METRICS diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx index fa2beffbffe0f..2833b0476428c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx @@ -13,11 +13,14 @@ import { EuiPanel, EuiSpacer, EuiStat, - EuiToolTip + EuiToolTip, + EuiCallOut } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; import { ApmHeader } from '../../shared/ApmHeader'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { useAgentName } from '../../../hooks/useAgentName'; @@ -26,6 +29,7 @@ import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; import { MetricsChart } from '../../shared/charts/MetricsChart'; import { useFetcher, FETCH_STATUS } from '../../../hooks/useFetcher'; import { truncate, px, unit } from '../../../style/variables'; +import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; const INITIAL_DATA = { host: '', @@ -38,7 +42,7 @@ const Truncate = styled.span` `; export function ServiceNodeMetrics() { - const { urlParams } = useUrlParams(); + const { urlParams, uiFilters } = useUrlParams(); const { serviceName, serviceNodeName } = urlParams; const { agentName } = useAgentName(); @@ -47,21 +51,28 @@ export function ServiceNodeMetrics() { const { data: { host, containerId } = INITIAL_DATA, status } = useFetcher( callApmApi => { - if (serviceName && serviceNodeName) { + if (serviceName && serviceNodeName && start && end) { return callApmApi({ pathname: '/api/apm/services/{serviceName}/node/{serviceNodeName}/metadata', params: { - path: { serviceName, serviceNodeName } + path: { serviceName, serviceNodeName }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters) + } } }); } }, - [serviceName, serviceNodeName] + [serviceName, serviceNodeName, start, end, uiFilters] ); const isLoading = status === FETCH_STATUS.LOADING; + const isAggregatedData = serviceNodeName === SERVICE_NODE_NAME_MISSING; + return (
@@ -74,39 +85,71 @@ export function ServiceNodeMetrics() { - - - - {host} - + {isAggregatedData ? ( + - - - + + {i18n.translate( + 'xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningDocumentationLink', + { defaultMessage: 'documentation of APM Server' } + )} + + ) + }} + > + + ) : ( + + + + {host} + } - )} - title={ - - {containerId} - - } - > - - + > + + + + {containerId} + + } + > + + + )} {agentName && serviceNodeName && ( diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx index 38cbd12f5a527..b69076b3a1f70 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx @@ -7,6 +7,8 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; +import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../common/i18n'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; import { PROJECTION } from '../../../../common/projections/typings'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { useUrlParams } from '../../../hooks/useUrlParams'; @@ -20,9 +22,9 @@ import { import { ServiceNodeMetricOverviewLink } from '../../shared/Links/apm/ServiceNodeMetricOverviewLink'; import { truncate, px, unit } from '../../../style/variables'; -const INITIAL_PAGE_SIZE = 10; -const INITIAL_SORT_FIELD = 'name'; -const INITIAL_SORT_DIRECTION = 'asc'; +const INITIAL_PAGE_SIZE = 25; +const INITIAL_SORT_FIELD = 'cpu'; +const INITIAL_SORT_DIRECTION = 'desc'; const ServiceNodeName = styled.div` ${truncate(px(8 * unit))} @@ -73,19 +75,43 @@ const ServiceNodeOverview = () => { const columns: Array> = [ { - name: i18n.translate('xpack.apm.jvmsTable.nameColumnLabel', { - defaultMessage: 'Name' - }), + name: ( + + <> + {i18n.translate('xpack.apm.jvmsTable.nameColumnLabel', { + defaultMessage: 'Name' + })} + + + ), field: 'name', sortable: true, render: (name: string) => { + const { displayedName, tooltip } = + name === SERVICE_NODE_NAME_MISSING + ? { + displayedName: UNIDENTIFIED_SERVICE_NODES_LABEL, + tooltip: i18n.translate( + 'xpack.apm.jvmsTable.explainServiceNodeNameMissing', + { + defaultMessage: + 'We could not identify which JVMs these metrics belong to. This is likely caused by running a version of APM Server that is older than 7.5. Upgrading to APM Server 7.5 or higher should resolve this issue.' + } + ) + } + : { displayedName: name, tooltip: name }; + return ( - + - {name} + {displayedName} ); @@ -93,7 +119,7 @@ const ServiceNodeOverview = () => { }, { name: i18n.translate('xpack.apm.jvmsTable.cpuColumnLabel', { - defaultMessage: 'CPU' + defaultMessage: 'CPU avg' }), field: 'cpu', sortable: true, @@ -101,7 +127,7 @@ const ServiceNodeOverview = () => { }, { name: i18n.translate('xpack.apm.jvmsTable.heapMemoryColumnLabel', { - defaultMessage: 'Heap memory max' + defaultMessage: 'Heap memory avg' }), field: 'heapMemory', sortable: true, @@ -109,7 +135,7 @@ const ServiceNodeOverview = () => { }, { name: i18n.translate('xpack.apm.jvmsTable.nonHeapMemoryColumnLabel', { - defaultMessage: 'Non-heap memory max' + defaultMessage: 'Non-heap memory avg' }), field: 'nonHeapMemory', sortable: true, @@ -117,7 +143,7 @@ const ServiceNodeOverview = () => { }, { name: i18n.translate('xpack.apm.jvmsTable.threadCountColumnLabel', { - defaultMessage: 'Thread count' + defaultMessage: 'Thread count max' }), field: 'threadCount', sortable: true, @@ -141,7 +167,6 @@ const ServiceNodeOverview = () => { initialPageSize={INITIAL_PAGE_SIZE} initialSortField={INITIAL_SORT_FIELD} initialSortDirection={INITIAL_SORT_DIRECTION} - hidePerPageOptions={false} /> diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx index 36d01555495a3..db0ddb56e7088 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx @@ -111,7 +111,7 @@ export const TransactionDistribution: FunctionComponent = ( ]); // no data in response - if (!distribution || !distribution.totalHits) { + if (!distribution || distribution.noHits) { // only show loading state if there is no data - else show stale data until new data has loaded if (isLoading) { return ; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx index b63671f63a1e7..53a9fc8bafbd1 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx @@ -12,7 +12,7 @@ import { metadata } from 'ui/metadata'; const STACK_VERSION = metadata.branch; // union type constisting of valid guide sections that we link to -type DocsSection = '/apm/get-started' | '/x-pack'; +type DocsSection = '/apm/get-started' | '/x-pack' | '/apm/server'; interface Props extends EuiLinkAnchorProps { section: DocsSection; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/__test__/HttpStatusBadge.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/__test__/HttpStatusBadge.test.tsx index a9400a8753b14..247e1c253d292 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/__test__/HttpStatusBadge.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/__test__/HttpStatusBadge.test.tsx @@ -6,56 +6,66 @@ import React from 'react'; import { mount } from 'enzyme'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; import { HttpStatusBadge } from '../index'; +import { + successColor, + neutralColor, + warningColor, + errorColor +} from '../../../../../utils/httpStatusCodeToColor'; describe('HttpStatusBadge', () => { describe('render', () => { describe('with status code 100', () => { - it('renders with the dark shade color', () => { + it('renders with neutral color', () => { const wrapper = mount(); expect(wrapper.find('HttpStatusBadge EuiBadge').prop('color')).toEqual( - theme.euiColorDarkShade + neutralColor ); }); }); + describe('with status code 200', () => { - it('renders with Secondary color', () => { + it('renders with success color', () => { const wrapper = mount(); expect(wrapper.find('HttpStatusBadge EuiBadge').prop('color')).toEqual( - theme.euiColorSecondary + successColor ); }); }); + describe('with status code 301', () => { - it('renders with dark shade color', () => { + it('renders with neutral color', () => { const wrapper = mount(); expect(wrapper.find('HttpStatusBadge EuiBadge').prop('color')).toEqual( - theme.euiColorDarkShade + neutralColor ); }); }); + describe('with status code 404', () => { - it('renders with Warning color', () => { + it('renders with warning color', () => { const wrapper = mount(); expect(wrapper.find('HttpStatusBadge EuiBadge').prop('color')).toEqual( - theme.euiColorWarning + warningColor ); }); }); + describe('with status code 502', () => { - it('renders with Danger color', () => { + it('renders with error color', () => { const wrapper = mount(); expect(wrapper.find('HttpStatusBadge EuiBadge').prop('color')).toEqual( - theme.euiColorDanger + errorColor ); }); }); + describe('with other status code', () => { it('renders with default color', () => { const wrapper = mount(); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/index.tsx index 9c351e1645d1b..76ec7565fb80e 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/index.tsx @@ -7,27 +7,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiToolTip, EuiBadge } from '@elastic/eui'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; import { statusCodes } from './statusCodes'; - -const { - euiColorDarkShade, - euiColorSecondary, - euiColorWarning, - euiColorDanger -} = theme; - -function getStatusColor(status: number) { - const colors: { [key: string]: string } = { - 1: euiColorDarkShade, - 2: euiColorSecondary, - 3: euiColorDarkShade, - 4: euiColorWarning, - 5: euiColorDanger - }; - - return colors[status.toString().substr(0, 1)] || 'default'; -} +import { httpStatusCodeToColor } from '../../../../utils/httpStatusCodeToColor'; interface HttpStatusBadgeProps { status: number; @@ -39,7 +20,7 @@ export function HttpStatusBadge({ status }: HttpStatusBadgeProps) { return ( - + {status} {statusCodes[status.toString()]} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/responseWithData.json b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/responseWithData.json index bdfeb2afb8c21..e8b96b501af0f 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/responseWithData.json +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/responseWithData.json @@ -247,5 +247,5 @@ } ], "overallAvgDuration": 467582.45401459857, - "totalHits": 999 + "noHits": false } diff --git a/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts b/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts index 5bffabe43fc77..51a632ac5f0a6 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts @@ -17,7 +17,7 @@ export function useServiceMetricCharts( urlParams: IUrlParams, agentName?: string ) { - const { serviceName, start, end } = urlParams; + const { serviceName, start, end, serviceNodeName } = urlParams; const uiFilters = useUiFilters(urlParams); const { data = INITIAL_DATA, error, status } = useFetcher( callApmApi => { @@ -30,13 +30,14 @@ export function useServiceMetricCharts( start, end, agentName, + serviceNodeName, uiFilters: JSON.stringify(uiFilters) } } }); } }, - [serviceName, start, end, agentName, uiFilters] + [serviceName, start, end, agentName, serviceNodeName, uiFilters] ); return { diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts b/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts index afd838e88b90d..4c7e337212e55 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts @@ -11,7 +11,7 @@ import { TransactionDistributionAPIResponse } from '../../server/lib/transaction const INITIAL_DATA = { buckets: [] as TransactionDistributionAPIResponse['buckets'], - totalHits: 0, + noHits: true, bucketSize: 0 }; diff --git a/x-pack/legacy/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts b/x-pack/legacy/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts index 741cb5f8415e8..80a1b96efb3d6 100644 --- a/x-pack/legacy/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts +++ b/x-pack/legacy/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts @@ -10,6 +10,11 @@ import { getResponseTimeSeries, getTpmSeries } from '../chartSelectors'; +import { + successColor, + warningColor, + errorColor +} from '../../utils/httpStatusCodeToColor'; describe('chartSelectors', () => { describe('getAnomalyScoreSeries', () => { @@ -93,21 +98,21 @@ describe('chartSelectors', () => { it('produces correct series', () => { expect(getTpmSeries(apmTimeseries, transactionType)).toEqual([ { - color: '#00b3a4', + color: successColor, data: [{ x: 0, y: 5 }, { x: 0, y: 2 }], legendValue: '3.5 tpm', title: 'HTTP 2xx', type: 'linemark' }, { - color: '#f98510', + color: warningColor, data: [{ x: 0, y: 1 }], legendValue: '1.0 tpm', title: 'HTTP 4xx', type: 'linemark' }, { - color: '#db1374', + color: errorColor, data: [{ x: 0, y: 0 }], legendValue: '0.0 tpm', title: 'HTTP 5xx', @@ -124,7 +129,7 @@ describe('chartSelectors', () => { ...apmTimeseries, tpmBuckets: [{ key, dataPoints: [{ x: 0, y: 0 }] }] })[0].color - ).toEqual(theme.euiColorVis0); + ).toEqual(theme.euiColorSecondary); }); }); @@ -136,7 +141,7 @@ describe('chartSelectors', () => { ...apmTimeseries, tpmBuckets: [{ key, dataPoints: [{ x: 0, y: 0 }] }] })[0].color - ).toEqual(theme.euiColorVis0); + ).toEqual(theme.euiColorSecondary); }); }); @@ -148,7 +153,7 @@ describe('chartSelectors', () => { ...apmTimeseries, tpmBuckets: [{ key, dataPoints: [{ x: 0, y: 0 }] }] })[0].color - ).toEqual(theme.euiColorVis0); + ).toEqual(theme.euiColorSecondary); }); }); @@ -160,7 +165,7 @@ describe('chartSelectors', () => { ...apmTimeseries, tpmBuckets: [{ key, dataPoints: [{ x: 0, y: 0 }] }] })[0].color - ).toEqual(theme.euiColorVis0); + ).toEqual(theme.euiColorSecondary); }); }); @@ -172,7 +177,7 @@ describe('chartSelectors', () => { ...apmTimeseries, tpmBuckets: [{ key, dataPoints: [{ x: 0, y: 0 }] }] })[0].color - ).toEqual(theme.euiColorVis2); + ).toEqual(theme.euiColorDanger); }); }); @@ -184,7 +189,7 @@ describe('chartSelectors', () => { ...apmTimeseries, tpmBuckets: [{ key, dataPoints: [{ x: 0, y: 0 }] }] })[0].color - ).toEqual(theme.euiColorVis2); + ).toEqual(theme.euiColorDanger); }); }); @@ -196,7 +201,7 @@ describe('chartSelectors', () => { ...apmTimeseries, tpmBuckets: [{ key, dataPoints: [{ x: 0, y: 0 }] }] })[0].color - ).toEqual(theme.euiColorVis2); + ).toEqual(theme.euiColorDanger); }); }); @@ -208,7 +213,7 @@ describe('chartSelectors', () => { ...apmTimeseries, tpmBuckets: [{ key, dataPoints: [{ x: 0, y: 0 }] }] })[0].color - ).toEqual(theme.euiColorVis2); + ).toEqual(theme.euiColorDanger); }); }); diff --git a/x-pack/legacy/plugins/apm/public/selectors/chartSelectors.ts b/x-pack/legacy/plugins/apm/public/selectors/chartSelectors.ts index a614d396b196e..4b65190d8ef22 100644 --- a/x-pack/legacy/plugins/apm/public/selectors/chartSelectors.ts +++ b/x-pack/legacy/plugins/apm/public/selectors/chartSelectors.ts @@ -20,6 +20,7 @@ import { import { asDecimal, asMillis, tpmUnit } from '../utils/formatters'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { getEmptySeries } from '../components/shared/charts/CustomPlot/getEmptySeries'; +import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor'; export interface ITpmBucket { title: string; @@ -181,21 +182,16 @@ export function getTpmSeries( function colorMatch(key: string) { if (/ok|success/i.test(key)) { - return theme.euiColorVis0; + return theme.euiColorSecondary; } else if (/error|fail/i.test(key)) { - return theme.euiColorVis2; + return theme.euiColorDanger; } } function getColorByKey(keys: string[]) { - const assignedColors: StringMap = { - 'HTTP 2xx': theme.euiColorVis0, - 'HTTP 3xx': theme.euiColorVis5, - 'HTTP 4xx': theme.euiColorVis7, - 'HTTP 5xx': theme.euiColorVis2 - }; + const assignedColors = ['HTTP 2xx', 'HTTP 3xx', 'HTTP 4xx', 'HTTP 5xx']; - const unknownKeys = difference(keys, Object.keys(assignedColors)); + const unknownKeys = difference(keys, assignedColors); const unassignedColors: StringMap = zipObject(unknownKeys, [ theme.euiColorVis1, theme.euiColorVis3, @@ -206,5 +202,5 @@ function getColorByKey(keys: string[]) { ]); return (key: string) => - colorMatch(key) || assignedColors[key] || unassignedColors[key]; + colorMatch(key) || httpStatusCodeToColor(key) || unassignedColors[key]; } diff --git a/x-pack/legacy/plugins/apm/public/utils/httpStatusCodeToColor.ts b/x-pack/legacy/plugins/apm/public/utils/httpStatusCodeToColor.ts new file mode 100644 index 0000000000000..db1ed490eb7f2 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/utils/httpStatusCodeToColor.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 theme from '@elastic/eui/dist/eui_theme_light.json'; +import { StringMap } from '../../typings/common'; + +const { euiColorDarkShade, euiColorWarning } = theme; + +export const errorColor = '#c23c2b'; +export const neutralColor = euiColorDarkShade; +export const successColor = '#327a42'; +export const warningColor = euiColorWarning; + +const httpStatusCodeColors: StringMap = { + 1: neutralColor, + 2: successColor, + 3: neutralColor, + 4: warningColor, + 5: errorColor +}; + +function getStatusColor(status: number) { + return httpStatusCodeColors[status.toString().substr(0, 1)]; +} + +/** + * Convert an HTTP status code to a color. + * + * If passed a string, it will remove all non-numeric characters + */ +export function httpStatusCodeToColor(status: string | number) { + if (typeof status === 'string') { + return getStatusColor(parseInt(status.replace(/\D/g, ''), 10)); + } else { + return getStatusColor(status); + } +} diff --git a/x-pack/legacy/plugins/apm/server/lib/errors/distribution/get_buckets.ts b/x-pack/legacy/plugins/apm/server/lib/errors/distribution/get_buckets.ts index 826d1061d5935..9c49a7f613aaa 100644 --- a/x-pack/legacy/plugins/apm/server/lib/errors/distribution/get_buckets.ts +++ b/x-pack/legacy/plugins/apm/server/lib/errors/distribution/get_buckets.ts @@ -72,7 +72,7 @@ export async function getBuckets({ })); return { - totalHits: resp.hits.total, + noHits: resp.hits.total.value === 0, buckets }; } diff --git a/x-pack/legacy/plugins/apm/server/lib/errors/distribution/get_distribution.ts b/x-pack/legacy/plugins/apm/server/lib/errors/distribution/get_distribution.ts index 21a4173fbeac2..81019b5261044 100644 --- a/x-pack/legacy/plugins/apm/server/lib/errors/distribution/get_distribution.ts +++ b/x-pack/legacy/plugins/apm/server/lib/errors/distribution/get_distribution.ts @@ -27,7 +27,7 @@ export async function getErrorDistribution({ setup: Setup; }) { const bucketSize = getBucketSize(setup); - const { buckets, totalHits } = await getBuckets({ + const { buckets, noHits } = await getBuckets({ serviceName, groupId, bucketSize, @@ -35,7 +35,7 @@ export async function getErrorDistribution({ }); return { - totalHits, + noHits, buckets, bucketSize }; diff --git a/x-pack/legacy/plugins/apm/server/lib/errors/get_error_group.ts b/x-pack/legacy/plugins/apm/server/lib/errors/get_error_group.ts index 6850615f4928b..dee5deb4064e4 100644 --- a/x-pack/legacy/plugins/apm/server/lib/errors/get_error_group.ts +++ b/x-pack/legacy/plugins/apm/server/lib/errors/get_error_group.ts @@ -67,6 +67,6 @@ export async function getErrorGroup({ return { transaction, error, - occurrencesCount: resp.hits.total + occurrencesCount: resp.hits.total.value }; } diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts index 896c558121992..03cfa7e0c8a83 100644 --- a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts @@ -10,7 +10,7 @@ import { IndexDocumentParams, IndicesDeleteParams, IndicesCreateParams, - AggregationSearchResponse + AggregationSearchResponseWithTotalHitsAsObject } from 'elasticsearch'; import { Legacy } from 'kibana'; import { cloneDeep, has, isString, set } from 'lodash'; @@ -76,8 +76,7 @@ async function getParamsForSearchRequest( const includeFrozen = await uiSettings.get('search:includeFrozen'); return { ...addFilterForLegacyData(apmIndices, params, apmOptions), // filter out pre-7.0 data - ignore_throttled: !includeFrozen, // whether to query frozen indices or not - rest_total_hits_as_int: true // ensure that ES returns accurate hits.total with pre-6.6 format + ignore_throttled: !includeFrozen // whether to query frozen indices or not }; } @@ -93,7 +92,7 @@ export function getESClient(req: Legacy.Request) { search: async ( params: U, apmOptions?: APMOptions - ): Promise> => { + ): Promise> => { const nextParams = await getParamsForSearchRequest( req, params, @@ -111,8 +110,12 @@ export function getESClient(req: Legacy.Request) { console.log(JSON.stringify(nextParams.body, null, 4)); } - return cluster.callWithRequest(req, 'search', nextParams) as Promise< - AggregationSearchResponse + return (cluster.callWithRequest( + req, + 'search', + nextParams + ) as unknown) as Promise< + AggregationSearchResponseWithTotalHitsAsObject >; }, index: (params: IndexDocumentParams) => { diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts index 91809fbaede03..6edac00b8a1ab 100644 --- a/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -42,8 +42,7 @@ describe('setupRequest', () => { } } }, - ignore_throttled: true, - rest_total_hits_as_int: true + ignore_throttled: true }); }); diff --git a/x-pack/legacy/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap b/x-pack/legacy/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap index 16d03c0b50498..0b1fe575f7254 100644 --- a/x-pack/legacy/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap +++ b/x-pack/legacy/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap @@ -1,6 +1,980 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`metrics queries fetches cpu chart data 1`] = ` +exports[`metrics queries with a service node name fetches cpu chart data 1`] = ` +Object { + "body": Object { + "aggs": Object { + "processCPUAverage": Object { + "avg": Object { + "field": "system.process.cpu.total.norm.pct", + }, + }, + "processCPUMax": Object { + "max": Object { + "field": "system.process.cpu.total.norm.pct", + }, + }, + "systemCPUAverage": Object { + "avg": Object { + "field": "system.cpu.total.norm.pct", + }, + }, + "systemCPUMax": Object { + "max": Object { + "field": "system.cpu.total.norm.pct", + }, + }, + "timeseriesData": Object { + "aggs": Object { + "processCPUAverage": Object { + "avg": Object { + "field": "system.process.cpu.total.norm.pct", + }, + }, + "processCPUMax": Object { + "max": Object { + "field": "system.process.cpu.total.norm.pct", + }, + }, + "systemCPUAverage": Object { + "avg": Object { + "field": "system.cpu.total.norm.pct", + }, + }, + "systemCPUMax": Object { + "max": Object { + "field": "system.cpu.total.norm.pct", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "10800s", + "min_doc_count": 0, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + +exports[`metrics queries with a service node name fetches heap memory chart data 1`] = ` +Object { + "body": Object { + "aggs": Object { + "heapMemoryCommitted": Object { + "avg": Object { + "field": "jvm.memory.heap.committed", + }, + }, + "heapMemoryMax": Object { + "avg": Object { + "field": "jvm.memory.heap.max", + }, + }, + "heapMemoryUsed": Object { + "avg": Object { + "field": "jvm.memory.heap.used", + }, + }, + "timeseriesData": Object { + "aggs": Object { + "heapMemoryCommitted": Object { + "avg": Object { + "field": "jvm.memory.heap.committed", + }, + }, + "heapMemoryMax": Object { + "avg": Object { + "field": "jvm.memory.heap.max", + }, + }, + "heapMemoryUsed": Object { + "avg": Object { + "field": "jvm.memory.heap.used", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "10800s", + "min_doc_count": 0, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + Object { + "term": Object { + "agent.name": "java", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + +exports[`metrics queries with a service node name fetches memory chart data 1`] = ` +Object { + "body": Object { + "aggs": Object { + "memoryUsedAvg": Object { + "avg": Object { + "script": Object { + "lang": "expression", + "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + }, + }, + }, + "memoryUsedMax": Object { + "max": Object { + "script": Object { + "lang": "expression", + "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + }, + }, + }, + "timeseriesData": Object { + "aggs": Object { + "memoryUsedAvg": Object { + "avg": Object { + "script": Object { + "lang": "expression", + "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + }, + }, + }, + "memoryUsedMax": Object { + "max": Object { + "script": Object { + "lang": "expression", + "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + }, + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "10800s", + "min_doc_count": 0, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + Object { + "exists": Object { + "field": "system.memory.actual.free", + }, + }, + Object { + "exists": Object { + "field": "system.memory.total", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + +exports[`metrics queries with a service node name fetches non heap memory chart data 1`] = ` +Object { + "body": Object { + "aggs": Object { + "nonHeapMemoryCommitted": Object { + "avg": Object { + "field": "jvm.memory.non_heap.committed", + }, + }, + "nonHeapMemoryMax": Object { + "avg": Object { + "field": "jvm.memory.non_heap.max", + }, + }, + "nonHeapMemoryUsed": Object { + "avg": Object { + "field": "jvm.memory.non_heap.used", + }, + }, + "timeseriesData": Object { + "aggs": Object { + "nonHeapMemoryCommitted": Object { + "avg": Object { + "field": "jvm.memory.non_heap.committed", + }, + }, + "nonHeapMemoryMax": Object { + "avg": Object { + "field": "jvm.memory.non_heap.max", + }, + }, + "nonHeapMemoryUsed": Object { + "avg": Object { + "field": "jvm.memory.non_heap.used", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "10800s", + "min_doc_count": 0, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + Object { + "term": Object { + "agent.name": "java", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + +exports[`metrics queries with a service node name fetches thread count chart data 1`] = ` +Object { + "body": Object { + "aggs": Object { + "threadCount": Object { + "avg": Object { + "field": "jvm.thread.count", + }, + }, + "threadCountMax": Object { + "max": Object { + "field": "jvm.thread.count", + }, + }, + "timeseriesData": Object { + "aggs": Object { + "threadCount": Object { + "avg": Object { + "field": "jvm.thread.count", + }, + }, + "threadCountMax": Object { + "max": Object { + "field": "jvm.thread.count", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "10800s", + "min_doc_count": 0, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + Object { + "term": Object { + "agent.name": "java", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + +exports[`metrics queries with service_node_name_missing fetches cpu chart data 1`] = ` +Object { + "body": Object { + "aggs": Object { + "processCPUAverage": Object { + "avg": Object { + "field": "system.process.cpu.total.norm.pct", + }, + }, + "processCPUMax": Object { + "max": Object { + "field": "system.process.cpu.total.norm.pct", + }, + }, + "systemCPUAverage": Object { + "avg": Object { + "field": "system.cpu.total.norm.pct", + }, + }, + "systemCPUMax": Object { + "max": Object { + "field": "system.cpu.total.norm.pct", + }, + }, + "timeseriesData": Object { + "aggs": Object { + "processCPUAverage": Object { + "avg": Object { + "field": "system.process.cpu.total.norm.pct", + }, + }, + "processCPUMax": Object { + "max": Object { + "field": "system.process.cpu.total.norm.pct", + }, + }, + "systemCPUAverage": Object { + "avg": Object { + "field": "system.cpu.total.norm.pct", + }, + }, + "systemCPUMax": Object { + "max": Object { + "field": "system.cpu.total.norm.pct", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "10800s", + "min_doc_count": 0, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "service.node.name", + }, + }, + ], + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + +exports[`metrics queries with service_node_name_missing fetches heap memory chart data 1`] = ` +Object { + "body": Object { + "aggs": Object { + "heapMemoryCommitted": Object { + "avg": Object { + "field": "jvm.memory.heap.committed", + }, + }, + "heapMemoryMax": Object { + "avg": Object { + "field": "jvm.memory.heap.max", + }, + }, + "heapMemoryUsed": Object { + "avg": Object { + "field": "jvm.memory.heap.used", + }, + }, + "timeseriesData": Object { + "aggs": Object { + "heapMemoryCommitted": Object { + "avg": Object { + "field": "jvm.memory.heap.committed", + }, + }, + "heapMemoryMax": Object { + "avg": Object { + "field": "jvm.memory.heap.max", + }, + }, + "heapMemoryUsed": Object { + "avg": Object { + "field": "jvm.memory.heap.used", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "10800s", + "min_doc_count": 0, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "service.node.name", + }, + }, + ], + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + Object { + "term": Object { + "agent.name": "java", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + +exports[`metrics queries with service_node_name_missing fetches memory chart data 1`] = ` +Object { + "body": Object { + "aggs": Object { + "memoryUsedAvg": Object { + "avg": Object { + "script": Object { + "lang": "expression", + "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + }, + }, + }, + "memoryUsedMax": Object { + "max": Object { + "script": Object { + "lang": "expression", + "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + }, + }, + }, + "timeseriesData": Object { + "aggs": Object { + "memoryUsedAvg": Object { + "avg": Object { + "script": Object { + "lang": "expression", + "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + }, + }, + }, + "memoryUsedMax": Object { + "max": Object { + "script": Object { + "lang": "expression", + "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + }, + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "10800s", + "min_doc_count": 0, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "service.node.name", + }, + }, + ], + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + Object { + "exists": Object { + "field": "system.memory.actual.free", + }, + }, + Object { + "exists": Object { + "field": "system.memory.total", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + +exports[`metrics queries with service_node_name_missing fetches non heap memory chart data 1`] = ` +Object { + "body": Object { + "aggs": Object { + "nonHeapMemoryCommitted": Object { + "avg": Object { + "field": "jvm.memory.non_heap.committed", + }, + }, + "nonHeapMemoryMax": Object { + "avg": Object { + "field": "jvm.memory.non_heap.max", + }, + }, + "nonHeapMemoryUsed": Object { + "avg": Object { + "field": "jvm.memory.non_heap.used", + }, + }, + "timeseriesData": Object { + "aggs": Object { + "nonHeapMemoryCommitted": Object { + "avg": Object { + "field": "jvm.memory.non_heap.committed", + }, + }, + "nonHeapMemoryMax": Object { + "avg": Object { + "field": "jvm.memory.non_heap.max", + }, + }, + "nonHeapMemoryUsed": Object { + "avg": Object { + "field": "jvm.memory.non_heap.used", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "10800s", + "min_doc_count": 0, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "service.node.name", + }, + }, + ], + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + Object { + "term": Object { + "agent.name": "java", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + +exports[`metrics queries with service_node_name_missing fetches thread count chart data 1`] = ` +Object { + "body": Object { + "aggs": Object { + "threadCount": Object { + "avg": Object { + "field": "jvm.thread.count", + }, + }, + "threadCountMax": Object { + "max": Object { + "field": "jvm.thread.count", + }, + }, + "timeseriesData": Object { + "aggs": Object { + "threadCount": Object { + "avg": Object { + "field": "jvm.thread.count", + }, + }, + "threadCountMax": Object { + "max": Object { + "field": "jvm.thread.count", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1528977600000, + "min": 1528113600000, + }, + "field": "@timestamp", + "fixed_interval": "10800s", + "min_doc_count": 0, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "service.node.name", + }, + }, + ], + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + Object { + "term": Object { + "agent.name": "java", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + +exports[`metrics queries without a service node name fetches cpu chart data 1`] = ` Object { "body": Object { "aggs": Object { @@ -94,7 +1068,7 @@ Object { } `; -exports[`metrics queries fetches heap memory chart data 1`] = ` +exports[`metrics queries without a service node name fetches heap memory chart data 1`] = ` Object { "body": Object { "aggs": Object { @@ -183,7 +1157,7 @@ Object { } `; -exports[`metrics queries fetches memory chart data 1`] = ` +exports[`metrics queries without a service node name fetches memory chart data 1`] = ` Object { "body": Object { "aggs": Object { @@ -279,7 +1253,7 @@ Object { } `; -exports[`metrics queries fetches non heap memory chart data 1`] = ` +exports[`metrics queries without a service node name fetches non heap memory chart data 1`] = ` Object { "body": Object { "aggs": Object { @@ -368,7 +1342,7 @@ Object { } `; -exports[`metrics queries fetches thread count chart data 1`] = ` +exports[`metrics queries without a service node name fetches thread count chart data 1`] = ` Object { "body": Object { "aggs": Object { diff --git a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/fetchAndTransformGcMetrics.ts b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/fetchAndTransformGcMetrics.ts index a43d8cd0dc2ea..7b8fc4ac44f64 100644 --- a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/fetchAndTransformGcMetrics.ts +++ b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/fetchAndTransformGcMetrics.ts @@ -10,7 +10,7 @@ */ import { idx } from '@kbn/elastic-idx'; -import { sum } from 'lodash'; +import { sum, round } from 'lodash'; import theme from '@elastic/eui/dist/eui_theme_light.json'; import { Setup } from '../../../../helpers/setup_request'; import { getMetricsDateHistogramParams } from '../../../../helpers/metrics'; @@ -23,6 +23,7 @@ import { METRIC_JAVA_GC_COUNT, METRIC_JAVA_GC_TIME } from '../../../../../../common/elasticsearch_fieldnames'; +import { getBucketSize } from '../../../../helpers/get_bucket_size'; const colors = [ theme.euiColorVis0, @@ -49,6 +50,8 @@ export async function fetchAndTransformGcMetrics({ }) { const { start, end, client } = setup; + const { bucketSize } = getBucketSize(start, end, 'auto'); + const projection = getMetricsProjection({ setup, serviceName, @@ -115,7 +118,7 @@ export async function fetchAndTransformGcMetrics({ if (!aggregations) { return { ...chartBase, - totalHits: 0, + noHits: true, series: [] }; } @@ -128,7 +131,7 @@ export async function fetchAndTransformGcMetrics({ // derivative/value will be undefined for the first hit and if the `max` value is null const y = 'value' in bucket && bucket.value.value !== null - ? bucket.value.value + ? round(bucket.value.value * (60 / bucketSize), 1) : null; return { @@ -153,7 +156,7 @@ export async function fetchAndTransformGcMetrics({ return { ...chartBase, - totalHits: response.hits.total, + noHits: response.hits.total.value === 0, series }; } diff --git a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/getGcRateChart.ts b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/getGcRateChart.ts index 7374171a36014..aa4533db0c33b 100644 --- a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/getGcRateChart.ts +++ b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/getGcRateChart.ts @@ -14,17 +14,17 @@ import { ChartBase } from '../../../types'; const series = { [METRIC_JAVA_GC_COUNT]: { title: i18n.translate('xpack.apm.agentMetrics.java.gcRate', { - defaultMessage: 'GC count' + defaultMessage: 'GC rate' }), color: theme.euiColorVis0 } }; const chartBase: ChartBase = { - title: i18n.translate('xpack.apm.agentMetrics.java.gcCountChartTitle', { - defaultMessage: 'Garbage collection count' + title: i18n.translate('xpack.apm.agentMetrics.java.gcRateChartTitle', { + defaultMessage: 'Garbage collection rate per minute' }), - key: 'gc_count_line_chart', + key: 'gc_rate_line_chart', type: 'linemark', yUnit: 'integer', series diff --git a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/getGcTimeChart.ts b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/getGcTimeChart.ts index 43c10f5b97c54..b6e992acf62a9 100644 --- a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/getGcTimeChart.ts +++ b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/java/gc/getGcTimeChart.ts @@ -22,7 +22,7 @@ const series = { const chartBase: ChartBase = { title: i18n.translate('xpack.apm.agentMetrics.java.gcTimeChartTitle', { - defaultMessage: 'Garbage collection time' + defaultMessage: 'Garbage collection time spent per minute' }), key: 'gc_time_line_chart', type: 'linemark', diff --git a/x-pack/legacy/plugins/apm/server/lib/metrics/queries.test.ts b/x-pack/legacy/plugins/apm/server/lib/metrics/queries.test.ts index 8ee835675e50e..f276fa69e20e3 100644 --- a/x-pack/legacy/plugins/apm/server/lib/metrics/queries.test.ts +++ b/x-pack/legacy/plugins/apm/server/lib/metrics/queries.test.ts @@ -13,45 +13,66 @@ import { SearchParamsMock, inspectSearchParams } from '../../../public/utils/testHelpers'; +import { SERVICE_NODE_NAME_MISSING } from '../../../common/service_nodes'; describe('metrics queries', () => { let mock: SearchParamsMock; - afterEach(() => { - mock.teardown(); - }); + const createTests = (serviceNodeName?: string) => { + it('fetches cpu chart data', async () => { + mock = await inspectSearchParams(setup => + getCPUChartData(setup, 'foo', serviceNodeName) + ); - it('fetches cpu chart data', async () => { - mock = await inspectSearchParams(setup => getCPUChartData(setup, 'foo')); + expect(mock.params).toMatchSnapshot(); + }); - expect(mock.params).toMatchSnapshot(); - }); + it('fetches memory chart data', async () => { + mock = await inspectSearchParams(setup => + getMemoryChartData(setup, 'foo', serviceNodeName) + ); - it('fetches memory chart data', async () => { - mock = await inspectSearchParams(setup => getMemoryChartData(setup, 'foo')); + expect(mock.params).toMatchSnapshot(); + }); - expect(mock.params).toMatchSnapshot(); - }); + it('fetches heap memory chart data', async () => { + mock = await inspectSearchParams(setup => + getHeapMemoryChart(setup, 'foo', serviceNodeName) + ); - it('fetches heap memory chart data', async () => { - mock = await inspectSearchParams(setup => getHeapMemoryChart(setup, 'foo')); + expect(mock.params).toMatchSnapshot(); + }); - expect(mock.params).toMatchSnapshot(); - }); + it('fetches non heap memory chart data', async () => { + mock = await inspectSearchParams(setup => + getNonHeapMemoryChart(setup, 'foo', serviceNodeName) + ); + + expect(mock.params).toMatchSnapshot(); + }); - it('fetches non heap memory chart data', async () => { - mock = await inspectSearchParams(setup => - getNonHeapMemoryChart(setup, 'foo') - ); + it('fetches thread count chart data', async () => { + mock = await inspectSearchParams(setup => + getThreadCountChart(setup, 'foo', serviceNodeName) + ); - expect(mock.params).toMatchSnapshot(); + expect(mock.params).toMatchSnapshot(); + }); + }; + + afterEach(() => { + mock.teardown(); }); - it('fetches thread count chart data', async () => { - mock = await inspectSearchParams(setup => - getThreadCountChart(setup, 'foo') - ); + describe('without a service node name', () => { + createTests(); + }); + + describe('with service_node_name_missing', () => { + createTests(SERVICE_NODE_NAME_MISSING); + }); - expect(mock.params).toMatchSnapshot(); + describe('with a service node name', () => { + createTests('bar'); }); }); diff --git a/x-pack/legacy/plugins/apm/server/lib/metrics/transform_metrics_chart.test.ts b/x-pack/legacy/plugins/apm/server/lib/metrics/transform_metrics_chart.test.ts index e077105e9b2e5..fe8f269fdab7c 100644 --- a/x-pack/legacy/plugins/apm/server/lib/metrics/transform_metrics_chart.test.ts +++ b/x-pack/legacy/plugins/apm/server/lib/metrics/transform_metrics_chart.test.ts @@ -8,7 +8,7 @@ import { ChartType, YUnit } from '../../../typings/timeseries'; test('transformDataToMetricsChart should transform an ES result into a chart object', () => { const response = { - hits: { total: 5000 }, + hits: { total: { value: 5000 } }, aggregations: { a: { value: 1000 }, b: { value: 1000 }, @@ -58,6 +58,7 @@ test('transformDataToMetricsChart should transform an ES result into a chart obj expect(chart).toMatchInlineSnapshot(` Object { "key": "test_chart_key", + "noHits": false, "series": Array [ Object { "color": "red", @@ -124,7 +125,6 @@ Object { }, ], "title": "Test Chart Title", - "totalHits": 5000, "yUnit": "number", } `); diff --git a/x-pack/legacy/plugins/apm/server/lib/metrics/transform_metrics_chart.ts b/x-pack/legacy/plugins/apm/server/lib/metrics/transform_metrics_chart.ts index af0605eb9d454..89bc3b4107a5f 100644 --- a/x-pack/legacy/plugins/apm/server/lib/metrics/transform_metrics_chart.ts +++ b/x-pack/legacy/plugins/apm/server/lib/metrics/transform_metrics_chart.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { AggregationSearchResponse, AggregatedValue } from 'elasticsearch'; +import { + AggregationSearchResponseWithTotalHitsAsObject, + AggregatedValue +} from 'elasticsearch'; import { idx } from '@kbn/elastic-idx'; import { ChartBase } from './types'; @@ -46,7 +49,7 @@ interface AggregatedParams { } export function transformDataToMetricsChart( - result: AggregationSearchResponse, + result: AggregationSearchResponseWithTotalHitsAsObject, chartBase: ChartBase ) { const { aggregations, hits } = result; @@ -56,7 +59,7 @@ export function transformDataToMetricsChart( title: chartBase.title, key: chartBase.key, yUnit: chartBase.yUnit, - totalHits: hits.total, + noHits: hits.total.value === 0, series: Object.keys(chartBase.series).map((seriesKey, i) => { const overallValue = idx(aggregations, _ => _[seriesKey].value); diff --git a/x-pack/legacy/plugins/apm/server/lib/service_nodes/__snapshots__/queries.test.ts.snap b/x-pack/legacy/plugins/apm/server/lib/service_nodes/__snapshots__/queries.test.ts.snap index d20adeba1bcc7..ce07759669d6b 100644 --- a/x-pack/legacy/plugins/apm/server/lib/service_nodes/__snapshots__/queries.test.ts.snap +++ b/x-pack/legacy/plugins/apm/server/lib/service_nodes/__snapshots__/queries.test.ts.snap @@ -1,5 +1,137 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`service node queries fetches metadata for a service node 1`] = ` +Object { + "body": Object { + "aggs": Object { + "containerId": Object { + "terms": Object { + "field": "container.id", + "size": 1, + }, + }, + "host": Object { + "terms": Object { + "field": "host.hostname", + "size": 1, + }, + }, + "nodes": Object { + "terms": Object { + "field": "service.node.name", + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + +exports[`service node queries fetches metadata for unidentified service nodes 1`] = ` +Object { + "body": Object { + "aggs": Object { + "containerId": Object { + "terms": Object { + "field": "container.id", + "size": 1, + }, + }, + "host": Object { + "terms": Object { + "field": "host.hostname", + "size": 1, + }, + }, + "nodes": Object { + "terms": Object { + "field": "service.node.name", + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "service.name": "foo", + }, + }, + Object { + "term": Object { + "processor.event": "metric", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "service.node.name", + }, + }, + ], + }, + }, + Object { + "term": Object { + "service.environment": "prod", + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", +} +`; + exports[`service node queries fetches services nodes 1`] = ` Object { "body": Object { @@ -29,6 +161,7 @@ Object { }, "terms": Object { "field": "service.node.name", + "missing": "_service_node_name_missing_", "size": 10000, }, }, diff --git a/x-pack/legacy/plugins/apm/server/lib/service_nodes/index.ts b/x-pack/legacy/plugins/apm/server/lib/service_nodes/index.ts index bef22584d8a46..afdf4795c4d29 100644 --- a/x-pack/legacy/plugins/apm/server/lib/service_nodes/index.ts +++ b/x-pack/legacy/plugins/apm/server/lib/service_nodes/index.ts @@ -7,6 +7,7 @@ import { Setup } from '../helpers/setup_request'; import { getServiceNodesProjection } from '../../../common/projections/service_nodes'; import { mergeProjection } from '../../../common/projections/util/merge_projection'; +import { SERVICE_NODE_NAME_MISSING } from '../../../common/service_nodes'; import { METRIC_PROCESS_CPU_PERCENT, METRIC_JAVA_THREAD_COUNT, @@ -30,7 +31,8 @@ const getServiceNodes = async ({ aggs: { nodes: { terms: { - size: 10000 + size: 10000, + missing: SERVICE_NODE_NAME_MISSING }, aggs: { cpu: { diff --git a/x-pack/legacy/plugins/apm/server/lib/service_nodes/queries.test.ts b/x-pack/legacy/plugins/apm/server/lib/service_nodes/queries.test.ts index b5b7e492386f4..80cd94b1549d7 100644 --- a/x-pack/legacy/plugins/apm/server/lib/service_nodes/queries.test.ts +++ b/x-pack/legacy/plugins/apm/server/lib/service_nodes/queries.test.ts @@ -14,6 +14,8 @@ import { SearchParamsMock, inspectSearchParams } from '../../../public/utils/testHelpers'; +import { getServiceNodeMetadata } from '../services/get_service_node_metadata'; +import { SERVICE_NODE_NAME_MISSING } from '../../../common/service_nodes'; describe('service node queries', () => { let mock: SearchParamsMock; @@ -29,4 +31,28 @@ describe('service node queries', () => { expect(mock.params).toMatchSnapshot(); }); + + it('fetches metadata for a service node', async () => { + mock = await inspectSearchParams(setup => + getServiceNodeMetadata({ + setup, + serviceName: 'foo', + serviceNodeName: 'bar' + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); + + it('fetches metadata for unidentified service nodes', async () => { + mock = await inspectSearchParams(setup => + getServiceNodeMetadata({ + setup, + serviceName: 'foo', + serviceNodeName: SERVICE_NODE_NAME_MISSING + }) + ); + + expect(mock.params).toMatchSnapshot(); + }); }); diff --git a/x-pack/legacy/plugins/apm/server/lib/services/get_service_node_metadata.ts b/x-pack/legacy/plugins/apm/server/lib/services/get_service_node_metadata.ts index 14bad7151757d..88e9670f4b444 100644 --- a/x-pack/legacy/plugins/apm/server/lib/services/get_service_node_metadata.ts +++ b/x-pack/legacy/plugins/apm/server/lib/services/get_service_node_metadata.ts @@ -8,12 +8,11 @@ import { idx } from '@kbn/elastic-idx'; import { Setup } from '../helpers/setup_request'; import { HOST_NAME, - SERVICE_NAME, - SERVICE_NODE_NAME, - CONTAINER_ID, - PROCESSOR_EVENT + CONTAINER_ID } from '../../../common/elasticsearch_fieldnames'; import { NOT_AVAILABLE_LABEL } from '../../../common/i18n'; +import { mergeProjection } from '../../../common/projections/util/merge_projection'; +import { getServiceNodesProjection } from '../../../common/projections/service_nodes'; export async function getServiceNodeMetadata({ serviceName, @@ -24,37 +23,34 @@ export async function getServiceNodeMetadata({ serviceNodeName: string; setup: Setup; }) { - const { client, config } = setup; + const { client } = setup; - const query = { - index: config.get('apm_oss.metricsIndices'), - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: 'metric' } }, - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [SERVICE_NODE_NAME]: serviceNodeName } } - ] - } - }, - aggs: { - host: { - terms: { - field: HOST_NAME, - size: 1 - } - }, - containerId: { - terms: { - field: CONTAINER_ID, - size: 1 + const query = mergeProjection( + getServiceNodesProjection({ + setup, + serviceName, + serviceNodeName + }), + { + body: { + size: 0, + aggs: { + host: { + terms: { + field: HOST_NAME, + size: 1 + } + }, + containerId: { + terms: { + field: CONTAINER_ID, + size: 1 + } } } } } - }; + ); const response = await client.search(query); diff --git a/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_agent_status.ts b/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_agent_status.ts index 68e5afd995203..d1654632dbb26 100644 --- a/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_agent_status.ts +++ b/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_agent_status.ts @@ -42,6 +42,6 @@ export async function getAgentStatus(setup: Setup) { }; const resp = await client.search(params); - const hasHistorialAgentData = resp.hits.total > 0; + const hasHistorialAgentData = resp.hits.total.value > 0; return hasHistorialAgentData; } diff --git a/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts b/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts index 2d59a4f028326..1379d79326add 100644 --- a/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts +++ b/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts @@ -31,6 +31,6 @@ export async function getLegacyDataStatus(setup: Setup) { }; const resp = await client.search(params, { includeLegacyData: true }); - const hasLegacyData = resp.hits.total > 0; + const hasLegacyData = resp.hits.total.value > 0; return hasLegacyData; } diff --git a/x-pack/legacy/plugins/apm/server/lib/traces/__snapshots__/queries.test.ts.snap b/x-pack/legacy/plugins/apm/server/lib/traces/__snapshots__/queries.test.ts.snap index 396e8540afdd6..4484f41d06ac8 100644 --- a/x-pack/legacy/plugins/apm/server/lib/traces/__snapshots__/queries.test.ts.snap +++ b/x-pack/legacy/plugins/apm/server/lib/traces/__snapshots__/queries.test.ts.snap @@ -54,6 +54,7 @@ Object { }, }, ], + "track_total_hits": true, }, "index": Array [ "myIndex", diff --git a/x-pack/legacy/plugins/apm/server/lib/traces/get_trace_items.ts b/x-pack/legacy/plugins/apm/server/lib/traces/get_trace_items.ts index 00eeefb4b4fcc..74e16424b1098 100644 --- a/x-pack/legacy/plugins/apm/server/lib/traces/get_trace_items.ts +++ b/x-pack/legacy/plugins/apm/server/lib/traces/get_trace_items.ts @@ -44,7 +44,8 @@ export async function getTraceItems(traceId: string, setup: Setup) { { _score: { order: 'asc' } }, { [TRANSACTION_DURATION]: { order: 'desc' } }, { [SPAN_DURATION]: { order: 'desc' } } - ] + ], + track_total_hits: true } }; @@ -52,6 +53,6 @@ export async function getTraceItems(traceId: string, setup: Setup) { return { items: resp.hits.hits.map(hit => hit._source), - exceedsMax: resp.hits.total > maxTraceItems + exceedsMax: resp.hits.total.value > maxTraceItems }; } diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts index 74980caf8c350..827194edd6aa3 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts @@ -39,7 +39,7 @@ export function bucketTransformer(response: DistributionBucketResponse) { ).map(getBucket); return { - totalHits: response.hits.total, + noHits: response.hits.total.value === 0, buckets }; } diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/index.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/index.ts index 454e247a19cae..3efa996d609d8 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/index.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/index.ts @@ -47,11 +47,11 @@ export async function getTransactionDistribution({ ); if (distributionMax == null) { - return { totalHits: 0, buckets: [], bucketSize: 0 }; + return { noHits: true, buckets: [], bucketSize: 0 }; } const bucketSize = getBucketSize(distributionMax, setup); - const { buckets, totalHits } = await getBuckets( + const { buckets, noHits } = await getBuckets( serviceName, transactionName, transactionType, @@ -63,7 +63,7 @@ export async function getTransactionDistribution({ ); return { - totalHits, + noHits, buckets, bucketSize }; diff --git a/x-pack/legacy/plugins/apm/server/routes/service_nodes.ts b/x-pack/legacy/plugins/apm/server/routes/service_nodes.ts index a87ef7d8e5878..285dd5b1f10f5 100644 --- a/x-pack/legacy/plugins/apm/server/routes/service_nodes.ts +++ b/x-pack/legacy/plugins/apm/server/routes/service_nodes.ts @@ -17,7 +17,7 @@ export const serviceNodesRoute = createRoute(core => ({ }), query: t.intersection([rangeRt, uiFiltersRt]) }, - handler: async (req, { path, query }) => { + handler: async (req, { path }) => { const setup = await setupRequest(req); const { serviceName } = path; diff --git a/x-pack/legacy/plugins/apm/server/routes/services.ts b/x-pack/legacy/plugins/apm/server/routes/services.ts index 99b8d3c7ba154..85d53925db86e 100644 --- a/x-pack/legacy/plugins/apm/server/routes/services.ts +++ b/x-pack/legacy/plugins/apm/server/routes/services.ts @@ -73,7 +73,8 @@ export const serviceNodeMetadataRoute = createRoute(() => ({ path: t.type({ serviceName: t.string, serviceNodeName: t.string - }) + }), + query: t.intersection([uiFiltersRt, rangeRt]) }, handler: async (req, { path }) => { const setup = await setupRequest(req); diff --git a/x-pack/legacy/plugins/apm/typings/elasticsearch.ts b/x-pack/legacy/plugins/apm/typings/elasticsearch.ts index c1a8a7f9b3985..10cd7bcbf4f38 100644 --- a/x-pack/legacy/plugins/apm/typings/elasticsearch.ts +++ b/x-pack/legacy/plugins/apm/typings/elasticsearch.ts @@ -81,6 +81,11 @@ declare module 'elasticsearch' { value: number | null; } + interface HitsTotal { + value: number; + relation: 'eq' | 'gte'; + } + type AggregationResultMap = IndexAsString< { [AggregationName in keyof AggregationOption]: { @@ -101,7 +106,7 @@ declare module 'elasticsearch' { >; top_hits: { hits: { - total: number; + total: HitsTotal; max_score: number | null; hits: Array<{ _source: AggregationOption[AggregationName] extends { @@ -148,7 +153,10 @@ declare module 'elasticsearch' { } >; - export type AggregationSearchResponse = Pick< + export type AggregationSearchResponseWithTotalHitsAsInt< + HitType, + SearchParams + > = Pick< SearchResponse, Exclude, 'aggregations'> > & @@ -158,6 +166,24 @@ declare module 'elasticsearch' { } : {}); + type Hits = Pick< + SearchResponse['hits'], + Exclude['hits'], 'total'> + > & { + total: HitsTotal; + }; + + export type AggregationSearchResponseWithTotalHitsAsObject< + HitType, + SearchParams + > = Pick< + AggregationSearchResponseWithTotalHitsAsInt, + Exclude< + keyof AggregationSearchResponseWithTotalHitsAsInt, + 'hits' + > + > & { hits: Hits }; + export interface ESFilter { [key: string]: { [key: string]: string | string[] | number | StringMap | ESFilter[]; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 097aef69d4b4c..894df9dc1c6b9 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -47,9 +47,10 @@ import { rounddate } from './rounddate'; import { rowCount } from './rowCount'; import { repeatImage } from './repeatImage'; import { revealImage } from './revealImage'; -import { savedMap } from './saved_map'; -import { savedSearch } from './saved_search'; -import { savedVisualization } from './saved_visualization'; +// TODO: elastic/kibana#44822 Disabling pending filters work +// import { savedMap } from './saved_map'; +// import { savedSearch } from './saved_search'; +// import { savedVisualization } from './saved_visualization'; import { seriesStyle } from './seriesStyle'; import { shape } from './shape'; import { sort } from './sort'; @@ -106,9 +107,10 @@ export const functions = [ revealImage, rounddate, rowCount, - savedMap, - savedSearch, - savedVisualization, + // TODO: elastic/kibana#44822 Disabling pending filters work + // savedMap, + // savedSearch, + // savedVisualization, seriesStyle, shape, sort, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts index c36a05e405704..8c8f53ad0d74e 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts @@ -35,6 +35,7 @@ interface SavedMapInput extends EmbeddableInput { type Return = EmbeddableExpression; export function savedMap(): ExpressionFunction<'savedMap', Filter | null, Arguments, Return> { + // @ts-ignore elastic/kibana#44822 Disabling pending filters work const { help, args: argHelp } = getFunctionHelp().savedMap; return { name: 'savedMap', diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts index 40bf235761b92..72ab334e68ca4 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts @@ -23,6 +23,7 @@ interface Arguments { type Return = EmbeddableExpression & { id: SearchInput['id'] }>; export function savedSearch(): ExpressionFunction<'savedSearch', Filter | null, Arguments, Return> { + // @ts-ignore elastic/kibana#44822 Disabling pending filters work const { help, args: argHelp } = getFunctionHelp().savedSearch; return { name: 'savedSearch', diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts index 930bdb74ee363..b3bc9b8ca69b7 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts @@ -26,6 +26,7 @@ export function savedVisualization(): ExpressionFunction< Arguments, Return > { + // @ts-ignore elastic/kibana#44822 Disabling pending filters work const { help, args: argHelp } = getFunctionHelp().savedVisualization; return { name: 'savedVisualization', diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/strings/functions/function_help.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/strings/functions/function_help.ts index 302f97d06ebed..327865ad1c61b 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/strings/functions/function_help.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/strings/functions/function_help.ts @@ -207,8 +207,12 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ revealImage, rounddate, rowCount, + // TODO: elastic/kibana#44822 Disabling pending filters work + // @ts-ignore savedMap, + // @ts-ignore savedSearch, + // @ts-ignore savedVisualization, seriesStyle, shape, diff --git a/x-pack/legacy/plugins/canvas/i18n/components.ts b/x-pack/legacy/plugins/canvas/i18n/components.ts index 3c73dd4090757..c7434a3814b38 100644 --- a/x-pack/legacy/plugins/canvas/i18n/components.ts +++ b/x-pack/legacy/plugins/canvas/i18n/components.ts @@ -465,6 +465,24 @@ export const ComponentStrings = { }, }), }, + FunctionFormContextError: { + getContextErrorMessage: (errorMessage: string) => + i18n.translate('xpack.canvas.functionForm.contextError', { + defaultMessage: 'ERROR: {errorMessage}', + values: { + errorMessage, + }, + }), + }, + FunctionFormFunctionUnknown: { + getUnknownArgumentTypeErrorMessage: (expressionType: string) => + i18n.translate('xpack.canvas.functionForm.functionUnknown.unknownArgumentTypeError', { + defaultMessage: 'Unknown expression type "{expressionType}"', + values: { + expressionType, + }, + }), + }, GroupSettings: { getSaveGroupDescription: () => i18n.translate('xpack.canvas.groupSettings.saveGroupDescription', { diff --git a/x-pack/legacy/plugins/canvas/public/components/function_form/function_form_context_error.js b/x-pack/legacy/plugins/canvas/public/components/function_form/function_form_context_error.js deleted file mode 100644 index 513d5a4138ad8..0000000000000 --- a/x-pack/legacy/plugins/canvas/public/components/function_form/function_form_context_error.js +++ /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 React from 'react'; -import PropTypes from 'prop-types'; - -export const FunctionFormContextError = ({ context }) => ( -
ERROR: {context.error}
-); - -FunctionFormContextError.propTypes = { - context: PropTypes.object, -}; diff --git a/x-pack/legacy/plugins/canvas/public/components/function_form/function_form_context_error.tsx b/x-pack/legacy/plugins/canvas/public/components/function_form/function_form_context_error.tsx new file mode 100644 index 0000000000000..2be047d0fb3b0 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/components/function_form/function_form_context_error.tsx @@ -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 React, { FunctionComponent } from 'react'; +import PropTypes from 'prop-types'; +import { ComponentStrings } from '../../../i18n/components'; + +interface Props { + context: { + error: string; + }; +} + +const { FunctionFormContextError: strings } = ComponentStrings; + +export const FunctionFormContextError: FunctionComponent = ({ context }) => ( +
+ {strings.getContextErrorMessage(context.error)} +
+); + +FunctionFormContextError.propTypes = { + context: PropTypes.shape({ error: PropTypes.string }).isRequired, +}; diff --git a/x-pack/legacy/plugins/canvas/public/components/function_form/function_unknown.js b/x-pack/legacy/plugins/canvas/public/components/function_form/function_unknown.tsx similarity index 53% rename from x-pack/legacy/plugins/canvas/public/components/function_form/function_unknown.js rename to x-pack/legacy/plugins/canvas/public/components/function_form/function_unknown.tsx index 462fce9f1c90a..c6b676ca0a166 100644 --- a/x-pack/legacy/plugins/canvas/public/components/function_form/function_unknown.js +++ b/x-pack/legacy/plugins/canvas/public/components/function_form/function_unknown.tsx @@ -4,12 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FunctionComponent } from 'react'; import PropTypes from 'prop-types'; +import { ComponentStrings } from '../../../i18n'; -export const FunctionUnknown = ({ argType }) => ( +interface Props { + /** the type of the argument */ + argType: string; +} +const { FunctionFormFunctionUnknown: strings } = ComponentStrings; + +export const FunctionUnknown: FunctionComponent = ({ argType }) => (
- Unknown expression type "{argType}" + {strings.getUnknownArgumentTypeErrorMessage(argType)}
); diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/workpad_header.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/workpad_header.tsx index 31ad0593f58bb..b05e1f5b757c2 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/workpad_header.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/workpad_header.tsx @@ -13,7 +13,6 @@ import { EuiFlexGroup, EuiButtonIcon, EuiButton, - EuiButtonEmpty, EuiOverlayMask, EuiModal, EuiModalFooter, @@ -194,11 +193,14 @@ export class WorkpadHeader extends React.PureComponent { + {/* + TODO: elastic/kibana#44822 Disabling pending filters work {strings.getEmbedObjectButtonLabel()} + */} { const git = await this.openGit(uri); const commit = await this.getCommitOr404(uri, revision); + if (!revision.includes('..')) { + revision = `${revision}..${revision}~1`; + } const diffs = await git.diffSummary([revision]); const commitDiff: CommitDiff = { diff --git a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/charts.tsx b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/charts.tsx index 7e481d515d64a..32c9ac36b5c52 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/charts.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/charts.tsx @@ -44,7 +44,7 @@ export const MetricsExplorerCharts = ({ timeRange, onTimeChange, }: Props) => { - if (!data && loading) { + if (loading) { return ( { if (!source) { - return null; + return ; } const { diff --git a/x-pack/legacy/plugins/infra/types/eui.d.ts b/x-pack/legacy/plugins/infra/types/eui.d.ts index f117181eae43d..530cda2d57731 100644 --- a/x-pack/legacy/plugins/infra/types/eui.d.ts +++ b/x-pack/legacy/plugins/infra/types/eui.d.ts @@ -96,6 +96,7 @@ declare module '@elastic/eui' { message?: any; rowProps?: any; cellProps?: any; + responsive?: boolean; }; export const EuiInMemoryTable: React.SFC; } diff --git a/x-pack/legacy/plugins/lens/server/routes/field_stats.ts b/x-pack/legacy/plugins/lens/server/routes/field_stats.ts index dee623329c1f1..e7e378e6b20e4 100644 --- a/x-pack/legacy/plugins/lens/server/routes/field_stats.ts +++ b/x-pack/legacy/plugins/lens/server/routes/field_stats.ts @@ -7,7 +7,7 @@ import Boom from 'boom'; import DateMath from '@elastic/datemath'; import { schema } from '@kbn/config-schema'; -import { AggregationSearchResponse } from 'elasticsearch'; +import { AggregationSearchResponseWithTotalHitsAsInt } from 'elasticsearch'; import { CoreSetup } from 'src/core/server'; import { FieldStatsResponse, BASE_API_URL } from '../../common'; @@ -135,10 +135,9 @@ export async function getNumberHistogram( }, }; - const minMaxResult = (await aggSearchWithBody(searchBody)) as AggregationSearchResponse< - unknown, - { body: { aggs: typeof searchBody } } - >; + const minMaxResult = (await aggSearchWithBody( + searchBody + )) as AggregationSearchResponseWithTotalHitsAsInt; const minValue = minMaxResult.aggregations!.sample.min_value.value; const maxValue = minMaxResult.aggregations!.sample.max_value.value; @@ -179,7 +178,9 @@ export async function getNumberHistogram( }, }, }; - const histogramResult = (await aggSearchWithBody(histogramBody)) as AggregationSearchResponse< + const histogramResult = (await aggSearchWithBody( + histogramBody + )) as AggregationSearchResponseWithTotalHitsAsInt< unknown, { body: { aggs: typeof histogramBody } } >; @@ -213,7 +214,9 @@ export async function getStringSamples( }, }, }; - const topValuesResult = (await aggSearchWithBody(topValuesBody)) as AggregationSearchResponse< + const topValuesResult = (await aggSearchWithBody( + topValuesBody + )) as AggregationSearchResponseWithTotalHitsAsInt< unknown, { body: { aggs: typeof topValuesBody } } >; @@ -260,7 +263,9 @@ export async function getDateHistogram( const histogramBody = { histo: { date_histogram: { field: field.name, fixed_interval: fixedInterval } }, }; - const results = (await aggSearchWithBody(histogramBody)) as AggregationSearchResponse< + const results = (await aggSearchWithBody( + histogramBody + )) as AggregationSearchResponseWithTotalHitsAsInt< unknown, { body: { aggs: typeof histogramBody } } >; diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.js b/x-pack/legacy/plugins/maps/public/actions/map_actions.js index fe6fbe9d8debf..b159cd02a7c28 100644 --- a/x-pack/legacy/plugins/maps/public/actions/map_actions.js +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.js @@ -23,7 +23,7 @@ import { unregisterCancelCallback } from '../reducers/non_serializable_instances'; import { updateFlyout } from '../actions/ui_actions'; -import { SOURCE_DATA_ID_ORIGIN } from '../../common/constants'; +import { FEATURE_ID_PROPERTY_NAME, SOURCE_DATA_ID_ORIGIN } from '../../common/constants'; export const SET_SELECTED_LAYER = 'SET_SELECTED_LAYER'; export const SET_TRANSIENT_LAYER = 'SET_TRANSIENT_LAYER'; @@ -203,11 +203,36 @@ function setLayerDataLoadErrorStatus(layerId, errorMessage) { }; } -export function clearTooltipStateForLayer(layerId) { +export function cleanTooltipStateForLayer(layerId, layerFeatures = []) { return (dispatch, getState) => { const tooltipState = getTooltipState(getState()); - if (tooltipState && tooltipState.layerId === layerId) { + + if (!tooltipState) { + return; + } + + const nextTooltipFeatures = tooltipState.features.filter(tooltipFeature => { + if (tooltipFeature.layerId !== layerId) { + // feature from another layer, keep it + return true; + } + + // Keep feature if it is still in layer + return layerFeatures.some(layerFeature => { + return layerFeature.properties[FEATURE_ID_PROPERTY_NAME] === tooltipFeature.id; + }); + }); + + if (tooltipState.features.length === nextTooltipFeatures.length) { + // no features got removed, nothing to update + return; + } + + if (nextTooltipFeatures.length === 0) { + // all features removed from tooltip, close tooltip dispatch(setTooltipState(null)); + } else { + dispatch(setTooltipState({ ...tooltipState, features: nextTooltipFeatures })); } }; } @@ -223,7 +248,7 @@ export function toggleLayerVisible(layerId) { const makeVisible = !layer.isVisible(); if (!makeVisible) { - dispatch(clearTooltipStateForLayer(layerId)); + dispatch(cleanTooltipStateForLayer(layerId)); } await dispatch({ @@ -456,7 +481,8 @@ export function updateSourceDataRequest(layerId, newData) { export function endDataLoad(layerId, dataId, requestToken, data, meta) { return async (dispatch) => { dispatch(unregisterCancelCallback(requestToken)); - dispatch(clearTooltipStateForLayer(layerId)); + const features = data ? data.features : []; + dispatch(cleanTooltipStateForLayer(layerId, features)); dispatch({ type: LAYER_DATA_LOAD_ENDED, layerId, @@ -478,7 +504,7 @@ export function endDataLoad(layerId, dataId, requestToken, data, meta) { export function onDataLoadError(layerId, dataId, requestToken, errorMessage) { return async (dispatch) => { dispatch(unregisterCancelCallback(requestToken)); - dispatch(clearTooltipStateForLayer(layerId)); + dispatch(cleanTooltipStateForLayer(layerId)); dispatch({ type: LAYER_DATA_LOAD_ERROR, data: null, @@ -599,7 +625,7 @@ export function removeLayer(layerId) { layerGettingRemoved.getInFlightRequestTokens().forEach(requestToken => { dispatch(cancelRequest(requestToken)); }); - dispatch(clearTooltipStateForLayer(layerId)); + dispatch(cleanTooltipStateForLayer(layerId)); layerGettingRemoved.destroy(); dispatch({ type: REMOVE_LAYER, diff --git a/x-pack/legacy/plugins/maps/public/elasticsearch_geo_utils.js b/x-pack/legacy/plugins/maps/public/elasticsearch_geo_utils.js index 10ef1ee517c3c..4a5715af64d0a 100644 --- a/x-pack/legacy/plugins/maps/public/elasticsearch_geo_utils.js +++ b/x-pack/legacy/plugins/maps/public/elasticsearch_geo_utils.js @@ -11,6 +11,7 @@ import { DECIMAL_DEGREES_PRECISION, ES_GEO_FIELD_TYPE, ES_SPATIAL_RELATIONS, + FEATURE_ID_PROPERTY_NAME, GEO_JSON_TYPE, POLYGON_COORDINATES_EXTERIOR_INDEX, LON_INDEX, @@ -74,6 +75,11 @@ export function hitsToGeoJson(hits, flattenHit, geoFieldName, geoFieldType) { // don't include geometry field value in properties delete properties[geoFieldName]; + // _id is unique to Elasticsearch documents. + // Move _id to FEATURE_ID_PROPERTY_NAME to standardize featureId keys across all sources + properties[FEATURE_ID_PROPERTY_NAME] = properties._id; + delete properties._id; + //create new geojson Feature for every individual geojson geometry. for (let j = 0; j < tmpGeometriesAccumulator.length; j++) { features.push({ diff --git a/x-pack/legacy/plugins/maps/public/elasticsearch_geo_utils.test.js b/x-pack/legacy/plugins/maps/public/elasticsearch_geo_utils.test.js index fe65a307f8905..5f2ae9fd7b19a 100644 --- a/x-pack/legacy/plugins/maps/public/elasticsearch_geo_utils.test.js +++ b/x-pack/legacy/plugins/maps/public/elasticsearch_geo_utils.test.js @@ -7,6 +7,7 @@ jest.mock('ui/new_platform'); jest.mock('ui/index_patterns'); +import { FEATURE_ID_PROPERTY_NAME } from '../common/constants'; import { hitsToGeoJson, geoPointToGeometry, @@ -15,7 +16,6 @@ import { convertMapExtentToPolygon, roundCoordinates, } from './elasticsearch_geo_utils'; - import { flattenHitWrapper } from 'ui/index_patterns'; const geoFieldName = 'location'; @@ -33,10 +33,33 @@ const flattenHitMock = hit => { properties[fieldName] = hit._source[fieldName]; } } + for (const fieldName in hit.fields) { + if (hit.fields.hasOwnProperty(fieldName)) { + properties[fieldName] = hit.fields[fieldName]; + } + } + properties._id = hit._id; + return properties; }; describe('hitsToGeoJson', () => { + it('Should set FEATURE_ID_PROPERTY_NAME to _id', () => { + const docId = 'if3mu20BBQNX22Q14Ppm'; + const hits = [ + { + _id: docId, + fields: { + [geoFieldName]: '20,100' + } + } + ]; + const geojson = hitsToGeoJson(hits, flattenHitMock, geoFieldName, 'geo_point'); + expect(geojson.type).toBe('FeatureCollection'); + expect(geojson.features.length).toBe(1); + expect(geojson.features[0].properties[FEATURE_ID_PROPERTY_NAME]).toBe(docId); + }); + it('Should convert elasitcsearch hits to geojson', () => { const hits = [ { @@ -63,7 +86,6 @@ describe('hitsToGeoJson', () => { }); }); - it('Should handle documents where geoField is not populated', () => { const hits = [ { diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js index 5faaf459dc314..3af1378e8e016 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/ems_file_source/ems_file_source.js @@ -7,7 +7,7 @@ import { AbstractVectorSource } from '../vector_source'; import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; import React from 'react'; -import { EMS_FILE } from '../../../../common/constants'; +import { EMS_FILE, FEATURE_ID_PROPERTY_NAME } from '../../../../common/constants'; import { getEMSClient } from '../../../meta'; import { EMSFileCreateSourceEditor } from './create_source_editor'; import { i18n } from '@kbn/i18n'; @@ -79,6 +79,16 @@ export class EMSFileSource extends AbstractVectorSource { featureCollectionPath: 'data', fetchUrl: emsFileLayer.getDefaultFormatUrl() }); + + const emsIdField = emsFileLayer._config.fields.find(field => { + return field.type === 'id'; + }); + featureCollection.features.forEach((feature, index) => { + feature.properties[FEATURE_ID_PROPERTY_NAME] = emsIdField + ? feature.properties[emsIdField.id] + : index; + }); + return { data: featureCollection, meta: {} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.js index 11003013f351a..e06568285dd6b 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/convert_to_geojson.js @@ -6,7 +6,7 @@ import { RENDER_AS } from './render_as'; import { getTileBoundingBox } from './geo_tile_utils'; -import { EMPTY_FEATURE_COLLECTION } from '../../../../common/constants'; +import { EMPTY_FEATURE_COLLECTION, FEATURE_ID_PROPERTY_NAME } from '../../../../common/constants'; export function convertToGeoJson({ table, renderAs }) { @@ -35,7 +35,9 @@ export function convertToGeoJson({ table, renderAs }) { return; } - const properties = {}; + const properties = { + [FEATURE_ID_PROPERTY_NAME]: gridKey + }; metricColumns.forEach(metricColumn => { properties[metricColumn.aggConfig.id] = row[metricColumn.id]; }); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.js index ce5efb3b44176..c334776e6c4e8 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/convert_to_lines.js @@ -6,6 +6,8 @@ import _ from 'lodash'; +import { FEATURE_ID_PROPERTY_NAME } from '../../../../common/constants'; + const LAT_INDEX = 0; const LON_INDEX = 1; @@ -27,7 +29,7 @@ export function convertToLines(esResponse) { const sourceBuckets = _.get(destBucket, 'sourceGrid.buckets', []); for (let j = 0; j < sourceBuckets.length; j++) { const { - key, // eslint-disable-line no-unused-vars + key, sourceCentroid, ...rest } = sourceBuckets[j]; @@ -46,6 +48,7 @@ export function convertToLines(esResponse) { coordinates: [[sourceCentroid.location.lon, sourceCentroid.location.lat], dest] }, properties: { + [FEATURE_ID_PROPERTY_NAME]: `${dest.join()},${key}`, ...rest } }); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js index 2fc0cf06b4020..502ed034a4416 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js @@ -14,7 +14,13 @@ import { SearchSource } from '../../../kibana_services'; import { hitsToGeoJson } from '../../../elasticsearch_geo_utils'; import { CreateSourceEditor } from './create_source_editor'; import { UpdateSourceEditor } from './update_source_editor'; -import { ES_SEARCH, ES_GEO_FIELD_TYPE, ES_SIZE_LIMIT, SORT_ORDER } from '../../../../common/constants'; +import { + ES_SEARCH, + ES_GEO_FIELD_TYPE, + ES_SIZE_LIMIT, + FEATURE_ID_PROPERTY_NAME, + SORT_ORDER, +} from '../../../../common/constants'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { ESTooltipProperty } from '../../tooltips/es_tooltip_property'; @@ -359,7 +365,7 @@ export class ESSearchSource extends AbstractESSource { async filterAndFormatPropertiesToHtml(properties) { const indexPattern = await this._getIndexPattern(); - const propertyValues = await this._loadTooltipProperties(properties._id, indexPattern); + const propertyValues = await this._loadTooltipProperties(properties[FEATURE_ID_PROPERTY_NAME], indexPattern); return this._descriptor.tooltipProperties.map(propertyName => { return new ESTooltipProperty(propertyName, propertyName, propertyValues[propertyName], indexPattern); @@ -466,7 +472,7 @@ export class ESSearchSource extends AbstractESSource { return { index: properties._index, // Can not use index pattern title because it may reference many indices - id: properties._id, + id: properties[FEATURE_ID_PROPERTY_NAME], path: geoField.name, }; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js index 0f52937f2211f..c75c5600aaf92 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/kibana_regionmap_source/kibana_regionmap_source.js @@ -10,6 +10,7 @@ import { CreateSourceEditor } from './create_source_editor'; import { getKibanaRegionList } from '../../../meta'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; +import { FEATURE_ID_PROPERTY_NAME } from '../../../../common/constants'; export class KibanaRegionmapSource extends AbstractVectorSource { @@ -80,6 +81,9 @@ export class KibanaRegionmapSource extends AbstractVectorSource { featureCollectionPath: vectorFileMeta.meta.feature_collection_path, fetchUrl: vectorFileMeta.url }); + featureCollection.features.forEach((feature, index) => { + feature.properties[FEATURE_ID_PROPERTY_NAME] = index; + }); return { data: featureCollection }; diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js index 829078d98996e..ffe2ce379436d 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js @@ -544,10 +544,8 @@ export class VectorLayer extends AbstractLayer { for (let i = 0; i < featureCollection.features.length; i++) { const id = randomizedIds[i]; const feature = featureCollection.features[i]; - feature.properties[FEATURE_ID_PROPERTY_NAME] = id; - feature.id = id; + feature.id = id; // Mapbox feature state id, must be integer } - } async syncData(syncContext) { diff --git a/x-pack/legacy/plugins/ml/common/util/job_utils.js b/x-pack/legacy/plugins/ml/common/util/job_utils.js index 518e95b405648..2bc8bfaff566e 100644 --- a/x-pack/legacy/plugins/ml/common/util/job_utils.js +++ b/x-pack/legacy/plugins/ml/common/util/job_utils.js @@ -380,15 +380,15 @@ export function basicJobValidation(job, fields, limits, skipMmlChecks = false) { messages.push({ id: 'bucket_span_empty' }); valid = false; } else { - const bucketSpan = parseInterval(job.analysis_config.bucket_span, false); - if (bucketSpan === null || bucketSpan.asMilliseconds() === 0) { - messages.push({ id: 'bucket_span_invalid' }); - valid = false; - } else { + if (isValidTimeFormat(job.analysis_config.bucket_span)) { messages.push({ id: 'bucket_span_valid', bucketSpan: job.analysis_config.bucket_span }); + + } else { + messages.push({ id: 'bucket_span_invalid' }); + valid = false; } } @@ -440,26 +440,29 @@ export function basicJobValidation(job, fields, limits, skipMmlChecks = false) { export function basicDatafeedValidation(datafeed) { const messages = []; - const valid = true; + let valid = true; if (datafeed) { - // if (_.isEmpty(datafeed.query)) { - // messages.push({ id: 'query_empty' }); - // valid = false; - // } else if (isValidJson(datafeed.query) === false) { - // messages.push({ id: 'query_invalid' }); - // valid = false; - // } else { - // messages.push({ id: 'query_valid' }); - // } - messages.push({ id: 'query_delay_valid' }); + let queryDelayMessage = { id: 'query_delay_valid' }; + if (isValidTimeFormat(datafeed.query_delay) === false) { + queryDelayMessage = { id: 'query_delay_invalid' }; + valid = false; + } + messages.push(queryDelayMessage); + + let frequencyMessage = { id: 'frequency_valid' }; + if (isValidTimeFormat(datafeed.frequency) === false) { + frequencyMessage = { id: 'frequency_invalid' }; + valid = false; + } + messages.push(frequencyMessage); } return { messages, valid, - contains: id => (messages.some(m => id === m.id)), - find: id => (messages.find(m => id === m.id)), + contains: id => messages.some(m => id === m.id), + find: id => messages.find(m => id === m.id), }; } @@ -532,6 +535,14 @@ export function validateGroupNames(job) { }; } +function isValidTimeFormat(value) { + if (value === undefined) { + return true; + } + const interval = parseInterval(value, false); + return (interval !== null && interval.asMilliseconds() !== 0); +} + // Returns the latest of the last source data and last processed bucket timestamp, // as used for example in setting the end time of results views for cases where // anomalies might have been raised after the point at which data ingest has stopped. diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_exploration/directive.tsx b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_exploration/directive.tsx index cf5a4cf957548..700be992a1080 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_exploration/directive.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_exploration/directive.tsx @@ -16,7 +16,7 @@ import { I18nContext } from 'ui/i18n'; import { IPrivate } from 'ui/private'; import { InjectorService } from '../../../../common/types/angular'; -import { SearchItemsProvider } from '../../../jobs/new_job/utils/new_job_utils'; +import { SearchItemsProvider } from '../../../jobs/new_job_new/utils/new_job_utils'; import { KibanaConfigTypeFix, KibanaContext } from '../../../contexts/kibana'; diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/directive.tsx b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/directive.tsx index 38b7b6df18991..5eb1981e9a5aa 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/directive.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/directive.tsx @@ -16,7 +16,7 @@ import { I18nContext } from 'ui/i18n'; import { IPrivate } from 'ui/private'; import { InjectorService } from '../../../../common/types/angular'; -import { SearchItemsProvider } from '../../../jobs/new_job/utils/new_job_utils'; +import { SearchItemsProvider } from '../../../jobs/new_job_new/utils/new_job_utils'; import { KibanaConfigTypeFix, KibanaContext } from '../../../contexts/kibana'; diff --git a/x-pack/legacy/plugins/ml/public/datavisualizer/file_based/file_datavisualizer_directive.js b/x-pack/legacy/plugins/ml/public/datavisualizer/file_based/file_datavisualizer_directive.js index 413e41efebbc3..fa4e0c2654e97 100644 --- a/x-pack/legacy/plugins/ml/public/datavisualizer/file_based/file_datavisualizer_directive.js +++ b/x-pack/legacy/plugins/ml/public/datavisualizer/file_based/file_datavisualizer_directive.js @@ -16,7 +16,7 @@ import { getFileDataVisualizerBreadcrumbs } from './breadcrumbs'; import { checkBasicLicense } from '../../license/check_license'; import { checkFindFileStructurePrivilege } from '../../privilege/check_privilege'; import { getMlNodeCount } from '../../ml_nodes_check/check_ml_nodes'; -import { loadNewJobDefaults } from '../../jobs/new_job/utils/new_job_defaults'; +import { loadNewJobDefaults } from '../../jobs/new_job_new/utils/new_job_defaults'; import { loadIndexPatterns } from '../../util/index_utils'; import { FileDataVisualizerPage } from './file_datavisualizer'; diff --git a/x-pack/legacy/plugins/ml/public/datavisualizer/index_based/directive.tsx b/x-pack/legacy/plugins/ml/public/datavisualizer/index_based/directive.tsx index 720ae09369116..e621bdd1d8b92 100644 --- a/x-pack/legacy/plugins/ml/public/datavisualizer/index_based/directive.tsx +++ b/x-pack/legacy/plugins/ml/public/datavisualizer/index_based/directive.tsx @@ -17,7 +17,7 @@ import { IPrivate } from 'ui/private'; import { InjectorService } from '../../../common/types/angular'; import { KibanaConfigTypeFix, KibanaContext } from '../../contexts/kibana/kibana_context'; -import { SearchItemsProvider } from '../../jobs/new_job/utils/new_job_utils'; +import { SearchItemsProvider } from '../../jobs/new_job_new/utils/new_job_utils'; import { Page } from './page'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js index a5519314c7c68..9e26ea820f600 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js @@ -24,8 +24,8 @@ import { import { toastNotifications } from 'ui/notify'; import { loadFullJob } from '../utils'; -import { mlCreateWatchService } from '../../../../jobs/new_job/simple/components/watcher/create_watch_service'; -import { CreateWatch } from '../../../../jobs/new_job/simple/components/watcher/create_watch_view'; +import { mlCreateWatchService } from './create_watch_service'; +import { CreateWatch } from './create_watch_view'; import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_service.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js similarity index 95% rename from x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_service.js rename to x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js index f114283eff102..d2a3ae009884f 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_service.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js @@ -7,17 +7,17 @@ import chrome from 'ui/chrome'; -import _ from 'lodash'; -import { http } from '../../../../../services/http_service'; +import { template } from 'lodash'; +import { http } from '../../../../services/http_service'; import emailBody from './email.html'; -import emailInfluencersBody from './email-influencers.html'; +import emailInfluencersBody from './email_influencers.html'; import { watch } from './watch.js'; import { i18n } from '@kbn/i18n'; -const compiledEmailBody = _.template(emailBody); -const compiledEmailInfluencersBody = _.template(emailInfluencersBody); +const compiledEmailBody = template(emailBody); +const compiledEmailInfluencersBody = template(emailInfluencersBody); const emailSection = { send_email: { diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_view.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js similarity index 96% rename from x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_view.js rename to x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js index bcd6bd733e118..716ece4b0e2dc 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_view.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js @@ -27,9 +27,9 @@ import { has } from 'lodash'; import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; -import { parseInterval } from '../../../../../../common/util/parse_interval'; -import { ml } from '../../../../../services/ml_api_service'; -import { SelectSeverity } from '../../../../../components/controls/select_severity/select_severity'; +import { parseInterval } from '../../../../../common/util/parse_interval'; +import { ml } from '../../../../services/ml_api_service'; +import { SelectSeverity } from '../../../../components/controls/select_severity/select_severity'; import { mlCreateWatchService } from './create_watch_service'; const STATUS = mlCreateWatchService.STATUS; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/email.html b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/email.html similarity index 100% rename from x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/email.html rename to x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/email.html diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/email-influencers.html b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/email_influencers.html similarity index 100% rename from x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/email-influencers.html rename to x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/email_influencers.html diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/watch.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/watch.js similarity index 98% rename from x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/watch.js rename to x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/watch.js index 447869ff8fdb4..c45894c36b702 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/watch.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/create_watch_flyout/watch.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ML_RESULTS_INDEX_PATTERN } from '../../../../../../common/constants/index_patterns'; +import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; export const watch = { trigger: { diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/edit_job_flyout/edit_utils.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/edit_job_flyout/edit_utils.js index b021b567777b8..8bd3d9228b857 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/edit_job_flyout/edit_utils.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/edit_job_flyout/edit_utils.js @@ -7,7 +7,7 @@ import { difference } from 'lodash'; import chrome from 'ui/chrome'; -import { newJobLimits } from 'plugins/ml/jobs/new_job/utils/new_job_defaults'; +import { newJobLimits } from 'plugins/ml/jobs/new_job_new/utils/new_job_defaults'; import { mlJobService } from 'plugins/ml/services/job_service'; import { processCreatedBy } from '../../../../../common/util/job_utils'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js index c9e431dee846e..e7e99830372d8 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/edit_job_flyout/tabs/datafeed.js @@ -19,7 +19,7 @@ import { } from '@elastic/eui'; import { calculateDatafeedFrequencyDefaultSeconds } from 'plugins/ml/../common/util/job_utils'; -import { newJobDefaults } from 'plugins/ml/jobs/new_job/utils/new_job_defaults'; +import { newJobDefaults } from 'plugins/ml/jobs/new_job_new/utils/new_job_defaults'; import { parseInterval } from 'plugins/ml/../common/util/parse_interval'; import { MLJobEditor } from '../../ml_job_editor'; import { FormattedMessage } from '@kbn/i18n/react'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/validate_job.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/validate_job.js index 98de6416cc577..05f9ec9d943f9 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/validate_job.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/validate_job.js @@ -5,8 +5,8 @@ */ -import { newJobLimits } from '../../new_job/utils/new_job_defaults'; -import { populateValidationMessages } from '../../new_job/simple/components/utils/validate_job'; +import { newJobLimits } from '../../new_job_new/utils/new_job_defaults'; +import { populateValidationMessages } from '../../new_job_new/common/job_validator/util'; import { validateModelMemoryLimit as validateModelMemoryLimitUtils, diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/directive.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/directive.js index 0b347a35ce73a..3267c78deecc1 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/directive.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/directive.js @@ -16,7 +16,7 @@ import { checkFullLicense } from 'plugins/ml/license/check_license'; import { checkGetJobsPrivilege } from 'plugins/ml/privilege/check_privilege'; import { getMlNodeCount } from 'plugins/ml/ml_nodes_check/check_ml_nodes'; import { getJobManagementBreadcrumbs } from 'plugins/ml/jobs/breadcrumbs'; -import { loadNewJobDefaults } from 'plugins/ml/jobs/new_job/utils/new_job_defaults'; +import { loadNewJobDefaults } from 'plugins/ml/jobs/new_job_new/utils/new_job_defaults'; import uiRoutes from 'ui/routes'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/post_save_options/post_save_options_directive.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/post_save_options/post_save_options_directive.js index f7aedd140c6af..73d66708bacca 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/post_save_options/post_save_options_directive.js +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/post_save_options/post_save_options_directive.js @@ -8,7 +8,7 @@ import { postSaveService } from './post_save_service'; import { i18n } from '@kbn/i18n'; -import { mlCreateWatchService } from 'plugins/ml/jobs/new_job/simple/components/watcher/create_watch_service'; +// import { mlCreateWatchService } from 'plugins/ml/jobs/new_job/simple/components/watcher/create_watch_service'; import { xpackFeatureAvailable } from 'plugins/ml/license/check_license'; import template from './post_save_options.html'; @@ -30,9 +30,9 @@ module.directive('mlPostSaveOptions', function () { $scope.status = postSaveService.status; $scope.STATUS = postSaveService.STATUS; - mlCreateWatchService.reset(); + // mlCreateWatchService.reset(); - mlCreateWatchService.config.includeInfluencers = $scope.includeInfluencers; + // mlCreateWatchService.config.includeInfluencers = $scope.includeInfluencers; $scope.runInRealtime = false; $scope.createWatch = false; $scope.embedded = true; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/post_save_options/post_save_service.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/post_save_options/post_save_service.js index a38a3cd689309..c910f9b3935e3 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/post_save_options/post_save_service.js +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/post_save_options/post_save_service.js @@ -9,7 +9,7 @@ import { mlJobService } from 'plugins/ml/services/job_service'; import { i18n } from '@kbn/i18n'; -import { mlCreateWatchService } from 'plugins/ml/jobs/new_job/simple/components/watcher/create_watch_service'; +// import { mlCreateWatchService } from 'plugins/ml/jobs/new_job/simple/components/watcher/create_watch_service'; import { mlMessageBarService } from 'plugins/ml/components/messagebar/messagebar_service'; const msgs = mlMessageBarService; @@ -26,7 +26,7 @@ class PostSaveService { realtimeJob: null, watch: null }; - mlCreateWatchService.status = this.status; + // mlCreateWatchService.status = this.status; this.externalCreateWatch; } @@ -63,11 +63,11 @@ class PostSaveService { this.startRealtimeJob(jobId, i18n) .then(() => { if (createWatch) { - mlCreateWatchService.createNewWatch(jobId) - .catch(() => {}) - .then(() => { - resolve(); - }); + // mlCreateWatchService.createNewWatch(jobId) + // .catch(() => {}) + // .then(() => { + // resolve(); + // }); } else { resolve(); } diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_directive.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_directive.js deleted file mode 100644 index f463f50cbccb0..0000000000000 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_directive.js +++ /dev/null @@ -1,73 +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 _ from 'lodash'; -import { parseInterval } from '../../../../../../common/util/parse_interval'; -import { mlCreateWatchService } from 'plugins/ml/jobs/new_job/simple/components/watcher/create_watch_service'; - -import template from './create_watch.html'; -import { ml } from 'plugins/ml/services/ml_api_service'; - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -module.directive('mlCreateWatch', function () { - return { - restrict: 'AE', - replace: false, - scope: { - jobId: '=', - bucketSpan: '=', - embedded: '=' - }, - template, - link: function ($scope) { - $scope.config = mlCreateWatchService.config; - $scope.status = mlCreateWatchService.status; - $scope.STATUS = mlCreateWatchService.STATUS; - - $scope.ui = { - emailEnabled: false, - embedded: $scope.embedded, - watchAlreadyExists: false - }; - - // make the interval 2 times the bucket span - if ($scope.bucketSpan) { - const interval = parseInterval($scope.bucketSpan); - let bs = interval.asMinutes() * 2; - if (bs < 1) { - bs = 1; - } - $scope.config.interval = `${bs}m`; - } - - // load elasticsearch settings to see if email has been configured - ml.getNotificationSettings() - .then((resp) => { - if (_.has(resp, 'defaults.xpack.notification.email')) { - $scope.ui.emailEnabled = true; - $scope.$applyAsync(); - } - }); - - // check to see whether a watch for this job has already been created. - // display a warning if it has. - mlCreateWatchService.loadWatch($scope.jobId) - .then(() => { - $scope.ui.watchAlreadyExists = true; - }) - .catch(() => { - $scope.ui.watchAlreadyExists = false; - }) - .then(() => { - $scope.$applyAsync(); - }); - } - }; -}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/index.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/index.js index 7fc4d77b0d229..21be947a3a54b 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/index.js +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/index.js @@ -6,6 +6,4 @@ -import './create_watch_directive.js'; -import './create_watch_service.js'; import '../severity_control'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/advanced_job_creator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/advanced_job_creator.ts index 510e4e5186e8e..ff42f391cb61a 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/advanced_job_creator.ts +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/advanced_job_creator.ts @@ -14,6 +14,7 @@ import { createBasicDetector } from './util/default_configs'; import { JOB_TYPE } from './util/constants'; import { getRichDetectors } from './util/general'; import { isValidJson } from '../../../../../common/util/validation_utils'; +import { ml } from '../../../../services/ml_api_service'; export interface RichDetector { agg: Aggregation | null; @@ -138,6 +139,21 @@ export class AdvancedJobCreator extends JobCreator { return isValidJson(this._queryString); } + // load the start and end times for the selected index + // and apply them to the job creator + public async autoSetTimeRange() { + try { + const { start, end } = await ml.getTimeFieldRange({ + index: this._indexPatternTitle, + timeFieldName: this.timeFieldName, + query: this.query, + }); + this.setTimeRange(start.epoch, end.epoch); + } catch (error) { + throw Error(error); + } + } + public cloneFromExistingJob(job: Job, datafeed: Datafeed) { this._overrideConfigs(job, datafeed); const detectors = getRichDetectors(job, datafeed, true); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/job_validator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/job_validator.ts index 88b7a66a623ed..358bbf67bee48 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/job_validator.ts +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/job_validator.ts @@ -6,7 +6,7 @@ import { ReactElement } from 'react'; import { basicJobValidation, basicDatafeedValidation } from '../../../../../common/util/job_utils'; -import { newJobLimits } from '../../../new_job/utils/new_job_defaults'; +import { newJobLimits } from '../../../new_job_new/utils/new_job_defaults'; import { JobCreatorType } from '../job_creator'; import { populateValidationMessages, checkForExistingJobAndGroupIds } from './util'; import { ExistingJobsAndGroups } from '../../../../services/job_service'; @@ -42,6 +42,7 @@ export class JobValidator { private _jobCreator: JobCreatorType; private _validationSummary: ValidationSummary; private _lastJobConfig: string; + private _lastDatafeedConfig: string; private _validateTimeout: ReturnType | null = null; private _existingJobsAndGroups: ExistingJobsAndGroups; private _basicValidations: BasicValidations = { @@ -60,6 +61,7 @@ export class JobValidator { constructor(jobCreator: JobCreatorType, existingJobsAndGroups: ExistingJobsAndGroups) { this._jobCreator = jobCreator; this._lastJobConfig = this._jobCreator.formattedJobJson; + this._lastDatafeedConfig = this._jobCreator.formattedDatafeedJson; this._validationSummary = { basic: false, advanced: false, @@ -70,13 +72,19 @@ export class JobValidator { public validate(callback: () => void, forceValidate: boolean = false) { this._validating = true; const formattedJobConfig = this._jobCreator.formattedJobJson; + const formattedDatafeedConfig = this._jobCreator.formattedDatafeedJson; // only validate if the config has changed - if (forceValidate || formattedJobConfig !== this._lastJobConfig) { + if ( + forceValidate || + formattedJobConfig !== this._lastJobConfig || + formattedDatafeedConfig !== this._lastDatafeedConfig + ) { if (this._validateTimeout !== null) { // clear any previous on going validation timeouts clearTimeout(this._validateTimeout); } this._lastJobConfig = formattedJobConfig; + this._lastDatafeedConfig = formattedDatafeedConfig; this._validateTimeout = setTimeout(() => { this._runBasicValidation(); this._validating = false; @@ -107,10 +115,15 @@ export class JobValidator { // run standard basic validation const basicJobResults = basicJobValidation(jobConfig, undefined, limits); - populateValidationMessages(basicJobResults, this._basicValidations, jobConfig); + populateValidationMessages(basicJobResults, this._basicValidations, jobConfig, datafeedConfig); const basicDatafeedResults = basicDatafeedValidation(datafeedConfig); - populateValidationMessages(basicDatafeedResults, this._basicValidations, jobConfig); + populateValidationMessages( + basicDatafeedResults, + this._basicValidations, + jobConfig, + datafeedConfig + ); // run addition job and group id validation const idResults = checkForExistingJobAndGroupIds( @@ -118,7 +131,7 @@ export class JobValidator { this._jobCreator.groups, this._existingJobsAndGroups ); - populateValidationMessages(idResults, this._basicValidations, jobConfig); + populateValidationMessages(idResults, this._basicValidations, jobConfig, datafeedConfig); this._validationSummary.basic = this._isOverallBasicValid(); } diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/util.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/util.ts index ca85e776fcf49..224d9ebf55823 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/util.ts +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/util.ts @@ -6,16 +6,17 @@ import { i18n } from '@kbn/i18n'; import { BasicValidations } from './job_validator'; -import { Job } from '../job_creator/configs'; +import { Job, Datafeed } from '../job_creator/configs'; import { ALLOWED_DATA_UNITS, JOB_ID_MAX_LENGTH } from '../../../../../common/constants/validation'; -import { newJobLimits } from '../../../new_job/utils/new_job_defaults'; +import { newJobLimits } from '../../../new_job_new/utils/new_job_defaults'; import { ValidationResults, ValidationMessage } from '../../../../../common/util/job_utils'; import { ExistingJobsAndGroups } from '../../../../services/job_service'; export function populateValidationMessages( validationResults: ValidationResults, basicValidations: BasicValidations, - jobConfig: Job + jobConfig: Job, + datafeedConfig: Datafeed ) { const limits = newJobLimits(); @@ -102,12 +103,13 @@ export function populateValidationMessages( if (validationResults.contains('model_memory_limit_invalid')) { basicValidations.modelMemoryLimit.valid = false; + const maxModelMemoryLimit = (limits.max_model_memory_limit || '').toUpperCase(); const msg = i18n.translate( 'xpack.ml.newJob.wizard.validateJob.modelMemoryLimitRangeInvalidErrorMessage', { defaultMessage: 'Model memory limit cannot be higher than the maximum value of {maxModelMemoryLimit}', - values: { maxModelMemoryLimit: limits.max_model_memory_limit.toUpperCase() }, + values: { maxModelMemoryLimit }, } ); basicValidations.modelMemoryLimit.message = msg; @@ -136,20 +138,9 @@ export function populateValidationMessages( basicValidations.bucketSpan.message = msg; } else if (validationResults.contains('bucket_span_invalid')) { basicValidations.bucketSpan.valid = false; - const msg = i18n.translate( - 'xpack.ml.newJob.wizard.validateJob.bucketSpanInvalidTimeIntervalFormatErrorMessage', - { - defaultMessage: - '{bucketSpan} is not a valid time interval format e.g. {tenMinutes}, {oneHour}. It also needs to be higher than zero.', - values: { - bucketSpan: jobConfig.analysis_config.bucket_span, - tenMinutes: '10m', - oneHour: '1h', - }, - } + basicValidations.bucketSpan.message = invalidTimeFormatMessage( + jobConfig.analysis_config.bucket_span ); - - basicValidations.bucketSpan.message = msg; } if (validationResults.contains('query_empty')) { @@ -165,6 +156,16 @@ export function populateValidationMessages( }); basicValidations.query.message = msg; } + + if (validationResults.contains('query_delay_invalid')) { + basicValidations.queryDelay.valid = false; + basicValidations.queryDelay.message = invalidTimeFormatMessage(datafeedConfig.query_delay); + } + + if (validationResults.contains('frequency_invalid')) { + basicValidations.frequency.valid = false; + basicValidations.frequency.message = invalidTimeFormatMessage(datafeedConfig.frequency); + } } export function checkForExistingJobAndGroupIds( @@ -196,3 +197,18 @@ export function checkForExistingJobAndGroupIds( find: (id: string) => messages.find(m => id === m.id), }; } + +function invalidTimeFormatMessage(value: string | undefined) { + return i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.frequencyInvalidTimeIntervalFormatErrorMessage', + { + defaultMessage: + '{value} is not a valid time interval format e.g. {tenMinutes}, {oneHour}. It also needs to be higher than zero.', + values: { + value, + tenMinutes: '10m', + oneHour: '1h', + }, + } + ); +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/common/model_memory_limit/model_memory_limit_input.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/common/model_memory_limit/model_memory_limit_input.tsx index b11395ea89759..3a3bd0c5a13a4 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/common/model_memory_limit/model_memory_limit_input.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/common/model_memory_limit/model_memory_limit_input.tsx @@ -6,7 +6,7 @@ import React, { FC, useState, useContext, useEffect } from 'react'; import { EuiFieldText } from '@elastic/eui'; -import { newJobDefaults } from '../../../../../new_job/utils/new_job_defaults'; +import { newJobDefaults } from '../../../../../new_job_new/utils/new_job_defaults'; import { JobCreatorContext } from '../../job_creator_context'; import { Description } from './description'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/datafeed_step/components/scroll_size/scroll_size_input.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/datafeed_step/components/scroll_size/scroll_size_input.tsx index ed01e2bc464a7..bc3b90688a78a 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/datafeed_step/components/scroll_size/scroll_size_input.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/datafeed_step/components/scroll_size/scroll_size_input.tsx @@ -6,7 +6,7 @@ import React, { FC, useState, useContext, useEffect } from 'react'; import { EuiFieldNumber } from '@elastic/eui'; -import { newJobDefaults } from '../../../../../../new_job/utils/new_job_defaults'; +import { newJobDefaults } from '../../../../../../new_job_new/utils/new_job_defaults'; import { JobCreatorContext } from '../../../job_creator_context'; import { Description } from './description'; @@ -20,7 +20,7 @@ export const ScrollSizeInput: FC = () => { ); const { datafeeds } = newJobDefaults(); - const { scroll_size: scrollSizeDefault } = datafeeds; + const scrollSizeDefault = datafeeds.scroll_size !== undefined ? `${datafeeds.scroll_size}` : ''; useEffect(() => { jobCreator.scrollSize = scrollSizeString === '' ? null : +scrollSizeString; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/directive.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/directive.tsx index 4ad689a943160..592dce3ce5b17 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/directive.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/job_type/directive.tsx @@ -17,7 +17,7 @@ import { I18nContext } from 'ui/i18n'; import { IPrivate } from 'ui/private'; import { InjectorService } from '../../../../../common/types/angular'; -import { SearchItemsProvider } from '../../../new_job/utils/new_job_utils'; +import { SearchItemsProvider } from '../../../new_job_new/utils/new_job_utils'; import { Page } from './page'; import { KibanaContext, KibanaConfigTypeFix } from '../../../../contexts/kibana'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx index 6513d9c69da1b..815e0228882ce 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx @@ -17,7 +17,7 @@ import { I18nContext } from 'ui/i18n'; import { IPrivate } from 'ui/private'; import { InjectorService } from '../../../../../common/types/angular'; -import { SearchItemsProvider } from '../../../new_job/utils/new_job_utils'; +import { SearchItemsProvider } from '../../utils/new_job_utils'; import { Page, PageProps } from './page'; import { JOB_TYPE } from '../../common/job_creator/util/constants'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx index 0d25c2b99a66a..017fdf901ef93 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx @@ -7,9 +7,11 @@ import React, { FC, useEffect, Fragment } from 'react'; import { EuiPage, EuiPageBody, EuiPageContentBody } from '@elastic/eui'; +import { toastNotifications } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; import { Wizard } from './wizard'; import { WIZARD_STEPS } from '../components/step_types'; -import { jobCreatorFactory } from '../../common/job_creator'; +import { jobCreatorFactory, isAdvancedJobCreator } from '../../common/job_creator'; import { JOB_TYPE, DEFAULT_MODEL_MEMORY_LIMIT, @@ -96,6 +98,21 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { // ML job config holds no reference to the saved search ID. jobCreator.createdBy = null; } + + if (isAdvancedJobCreator(jobCreator)) { + // for advanced jobs, load the full time range start and end times + // so they can be used for job validation and bucket span estimation + try { + jobCreator.autoSetTimeRange(); + } catch (error) { + toastNotifications.addDanger({ + title: i18n.translate('xpack.ml.newJob.wizard.autoSetJobCreatorTimeRange.error', { + defaultMessage: `Error retrieving beginning and end times of index`, + }), + text: error, + }); + } + } } const chartInterval = new TimeBuckets(); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts index 6301d38aee490..964dc1eee5140 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts @@ -22,7 +22,7 @@ import { Route } from '../../../../../common/types/kibana'; import { loadNewJobCapabilities } from '../../../../services/new_job_capabilities_service'; -import { loadNewJobDefaults } from '../../../new_job/utils/new_job_defaults'; +import { loadNewJobDefaults } from '../../utils/new_job_defaults'; import { mlJobService } from '../../../../services/job_service'; import { JOB_TYPE } from '../../common/job_creator/util/constants'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx index 5755a381aaadd..890db5e29387a 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx @@ -20,6 +20,7 @@ import { JobValidator } from '../../common/job_validator'; import { newJobCapsService } from '../../../../services/new_job_capabilities_service'; import { WizardSteps } from './wizard_steps'; import { WizardHorizontalSteps } from './wizard_horizontal_steps'; +import { JOB_TYPE } from '../../common/job_creator/util/constants'; interface Props { jobCreator: JobCreatorType; @@ -62,8 +63,13 @@ export const Wizard: FC = ({ existingJobsAndGroups, }; - const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.TIME_RANGE); - const [highestStep, setHighestStep] = useState(WIZARD_STEPS.TIME_RANGE); + const firstStep = + jobCreator.type === JOB_TYPE.ADVANCED + ? WIZARD_STEPS.ADVANCED_CONFIGURE_DATAFEED + : WIZARD_STEPS.TIME_RANGE; + + const [currentStep, setCurrentStep] = useState(firstStep); + const [highestStep, setHighestStep] = useState(firstStep); const [disableSteps, setDisableSteps] = useState(false); const [progress, setProgress] = useState(resultsLoader.progress); const [stringifiedConfigs, setStringifiedConfigs] = useState( diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/recognize/directive.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/recognize/directive.tsx index 593db836b5ddb..cf80cad30b7ca 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/recognize/directive.tsx +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/recognize/directive.tsx @@ -17,7 +17,7 @@ import { I18nContext } from 'ui/i18n'; import { IPrivate } from 'ui/private'; import { InjectorService } from '../../../../common/types/angular'; -import { SearchItemsProvider } from '../../new_job/utils/new_job_utils'; +import { SearchItemsProvider } from '../../new_job_new/utils/new_job_utils'; import { Page } from './page'; import { KibanaContext, KibanaConfigTypeFix } from '../../../contexts/kibana'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/utils/new_job_defaults.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/utils/new_job_defaults.ts new file mode 100644 index 0000000000000..c86a5a7861b64 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/utils/new_job_defaults.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ml } from '../../../services/ml_api_service'; + +export interface MlServerDefaults { + anomaly_detectors: { + categorization_examples_limit?: number; + model_memory_limit?: string; + model_snapshot_retention_days?: number; + }; + datafeeds: { scroll_size?: number }; +} + +export interface MlServerLimits { + max_model_memory_limit?: string; +} + +let defaults: MlServerDefaults = { + anomaly_detectors: {}, + datafeeds: {}, +}; +let limits: MlServerLimits = {}; + +export async function loadNewJobDefaults() { + try { + const resp = await ml.mlInfo(); + defaults = resp.defaults; + limits = resp.limits; + return { defaults, limits }; + } catch (error) { + return { defaults, limits }; + } +} + +export function newJobDefaults(): MlServerDefaults { + return defaults; +} + +export function newJobLimits(): MlServerLimits { + return limits; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/utils/new_job_utils.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/utils/new_job_utils.ts new file mode 100644 index 0000000000000..0a37cff050eb0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/utils/new_job_utils.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 { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPattern } from 'ui/index_patterns'; +import { buildEsQuery, getEsQueryConfig } from '@kbn/es-query'; +import { KibanaConfigTypeFix } from '../../../contexts/kibana'; +import { InjectorService } from '../../../../common/types/angular'; + +export interface SearchItems { + indexPattern: IndexPattern; + savedSearch: SavedSearch; + query: any; + combinedQuery: any; +} + +// Provider for creating the items used for searching and job creation. +// Uses the $route object to retrieve the indexPattern and savedSearch from the url +export function SearchItemsProvider($injector: InjectorService) { + const kibanaConfig = $injector.get('config'); + const $route = $injector.get('$route'); + + function createSearchItems() { + let indexPattern = $route.current.locals.indexPattern; + + // query is only used by the data visualizer as it needs + // a lucene query_string. + // Using a blank query will cause match_all:{} to be used + // when passed through luceneStringToDsl + let query = { + query: '', + language: 'lucene', + }; + + let combinedQuery: any = { + bool: { + must: [ + { + match_all: {}, + }, + ], + }, + }; + + const savedSearch = $route.current.locals.savedSearch; + if (indexPattern.id === undefined && savedSearch.id !== undefined) { + const searchSource = savedSearch.searchSource; + indexPattern = searchSource.getField('index'); + + query = searchSource.getField('query'); + const fs = searchSource.getField('filter'); + + const filters = fs.length ? fs : []; + + const esQueryConfigs = getEsQueryConfig(kibanaConfig); + combinedQuery = buildEsQuery(indexPattern, [query], filters, esQueryConfigs); + } + + return { + indexPattern, + savedSearch, + query, + combinedQuery, + }; + } + + return createSearchItems; +} + +// Only model plot cardinality relevant +// format:[{id:"cardinality_model_plot_high",modelPlotCardinality:11405}, {id:"cardinality_partition_field",fieldName:"clientip"}] +interface CheckCardinalitySuccessResponse { + success: boolean; + highCardinality?: any; +} +export function checkCardinalitySuccess(data: any) { + const response: CheckCardinalitySuccessResponse = { + success: true, + }; + // There were no fields to run cardinality on. + if (Array.isArray(data) && data.length === 0) { + return response; + } + + for (let i = 0; i < data.length; i++) { + if (data[i].id === 'success_cardinality') { + break; + } + + if (data[i].id === 'cardinality_model_plot_high') { + response.success = false; + response.highCardinality = data[i].modelPlotCardinality; + break; + } + } + + return response; +} diff --git a/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts b/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts index b63919041e139..7962fedd20fee 100644 --- a/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts +++ b/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts @@ -9,6 +9,7 @@ import { AggFieldNamePair } from '../../../common/types/fields'; import { ExistingJobsAndGroups } from '../job_service'; import { PrivilegesResponse } from '../../../common/types/privileges'; import { MlSummaryJobs } from '../../../common/types/jobs'; +import { MlServerDefaults, MlServerLimits } from '../../jobs/new_job_new/utils/new_job_defaults'; // TODO This is not a complete representation of all methods of `ml.*`. // It just satisfies needs for other parts of the code area which use @@ -24,6 +25,16 @@ export interface GetTimeFieldRangeResponse { end: { epoch: number; string: string }; } +export interface MlInfoResponse { + defaults: MlServerDefaults; + limits: MlServerLimits; + native_code: { + build_hash: string; + version: string; + }; + upgrade_mode: boolean; +} + declare interface Ml { annotations: { deleteAnnotation(id: string | undefined): Promise; @@ -119,6 +130,7 @@ declare interface Ml { ): Promise<{ name: string; ms: number; error?: boolean; message?: { msg: string } | string }>; mlNodeCount(): Promise<{ count: number }>; + mlInfo(): Promise; } declare const ml: Ml; diff --git a/x-pack/legacy/plugins/ml/server/lib/spaces_utils.ts b/x-pack/legacy/plugins/ml/server/lib/spaces_utils.ts index 0ebce3d1451bf..115e7fe6ba434 100644 --- a/x-pack/legacy/plugins/ml/server/lib/spaces_utils.ts +++ b/x-pack/legacy/plugins/ml/server/lib/spaces_utils.ts @@ -5,7 +5,7 @@ */ import { Request } from 'hapi'; -import { SpacesPlugin } from '../../../spaces'; +import { LegacySpacesPlugin } from '../../../spaces'; import { Space } from '../../../spaces/common/model/space'; interface GetActiveSpaceResponse { @@ -13,7 +13,7 @@ interface GetActiveSpaceResponse { space?: Space; } -export function spacesUtilsProvider(spacesPlugin: SpacesPlugin, request: Request) { +export function spacesUtilsProvider(spacesPlugin: LegacySpacesPlugin, request: Request) { async function activeSpace(): Promise { try { return { diff --git a/x-pack/legacy/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/legacy/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index fd33bc79eeaf8..3a872b4c1e327 100644 --- a/x-pack/legacy/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/legacy/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -68,7 +68,7 @@ class GetCsvReportPanelAction implements IAction { return {}; } - return searchEmbeddable.searchScope.searchSource.getSearchRequestBody(); + return searchEmbeddable.getSavedSearch().searchSource.getSearchRequestBody(); } public isCompatible = async (context: ActionContext) => { diff --git a/x-pack/legacy/plugins/security/common/constants.ts b/x-pack/legacy/plugins/security/common/constants.ts index 2a255ecd335e5..2ec429b4d9c4c 100644 --- a/x-pack/legacy/plugins/security/common/constants.ts +++ b/x-pack/legacy/plugins/security/common/constants.ts @@ -8,3 +8,4 @@ export const GLOBAL_RESOURCE = '*'; export const IGNORED_TYPES = ['space']; export const APPLICATION_PREFIX = 'kibana-'; export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; +export const INTERNAL_API_BASE_PATH = '/internal/security'; diff --git a/x-pack/legacy/plugins/security/common/model/api_key.ts b/x-pack/legacy/plugins/security/common/model/api_key.ts new file mode 100644 index 0000000000000..acdf999da4a0f --- /dev/null +++ b/x-pack/legacy/plugins/security/common/model/api_key.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. + */ + +export interface ApiKey { + id: string; + name: string; + username: string; + realm: string; + creation: number; + expiration: number; + invalidated: boolean; +} + +export interface ApiKeyToInvalidate { + id: string; + name: string; +} diff --git a/x-pack/legacy/plugins/security/common/model/index.ts b/x-pack/legacy/plugins/security/common/model/index.ts index 31757543ac3f8..19243c25fef7e 100644 --- a/x-pack/legacy/plugins/security/common/model/index.ts +++ b/x-pack/legacy/plugins/security/common/model/index.ts @@ -8,6 +8,7 @@ export { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role'; export { FeaturesPrivileges } from './features_privileges'; export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; export { KibanaPrivileges } from './kibana_privileges'; +export { ApiKey } from './api_key'; export { User, EditUser, getUserDisplayName } from '../../../../../plugins/security/common/model'; export { AuthenticatedUser, diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index 980af19cc8362..f9e82f575ce2e 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -7,6 +7,7 @@ import { resolve } from 'path'; import { initAuthenticateApi } from './server/routes/api/v1/authenticate'; import { initUsersApi } from './server/routes/api/v1/users'; +import { initApiKeysApi } from './server/routes/api/v1/api_keys'; import { initExternalRolesApi } from './server/routes/api/external/roles'; import { initPrivilegesApi } from './server/routes/api/external/privileges'; import { initIndicesApi } from './server/routes/api/v1/indices'; @@ -195,6 +196,7 @@ export const security = (kibana) => new kibana.Plugin({ initAPIAuthorization(server, authorization); initAppAuthorization(server, xpackMainPlugin, authorization); initUsersApi(securityPlugin, server); + initApiKeysApi(server); initExternalRolesApi(server); initIndicesApi(server); initPrivilegesApi(server); diff --git a/x-pack/legacy/plugins/security/public/lib/api_keys_api.ts b/x-pack/legacy/plugins/security/public/lib/api_keys_api.ts new file mode 100644 index 0000000000000..c6dcef392af98 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/lib/api_keys_api.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 { kfetch } from 'ui/kfetch'; +import { ApiKey, ApiKeyToInvalidate } from '../../common/model/api_key'; +import { INTERNAL_API_BASE_PATH } from '../../common/constants'; + +interface CheckPrivilegesResponse { + areApiKeysEnabled: boolean; + isAdmin: boolean; +} + +interface InvalidateApiKeysResponse { + itemsInvalidated: ApiKeyToInvalidate[]; + errors: any[]; +} + +interface GetApiKeysResponse { + apiKeys: ApiKey[]; +} + +const apiKeysUrl = `${INTERNAL_API_BASE_PATH}/api_key`; + +export class ApiKeysApi { + public static async checkPrivileges(): Promise { + return kfetch({ pathname: `${apiKeysUrl}/privileges` }); + } + + public static async getApiKeys(isAdmin: boolean = false): Promise { + const query = { + isAdmin, + }; + + return kfetch({ pathname: apiKeysUrl, query }); + } + + public static async invalidateApiKeys( + apiKeys: ApiKeyToInvalidate[], + isAdmin: boolean = false + ): Promise { + const pathname = `${apiKeysUrl}/invalidate`; + const body = JSON.stringify({ apiKeys, isAdmin }); + return kfetch({ pathname, method: 'POST', body }); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.html b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.html new file mode 100644 index 0000000000000..e46c6f72b5d20 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.html @@ -0,0 +1,3 @@ + +
+ diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.js b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.js new file mode 100644 index 0000000000000..f143df8c9742f --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/api_keys.js @@ -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 React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import routes from 'ui/routes'; +import template from './api_keys.html'; +import { API_KEYS_PATH } from '../management_urls'; +import { getApiKeysBreadcrumbs } from '../breadcrumbs'; +import { I18nContext } from 'ui/i18n'; +import { ApiKeysGridPage } from './components'; + +routes.when(API_KEYS_PATH, { + template, + k7Breadcrumbs: getApiKeysBreadcrumbs, + controller($scope) { + $scope.$$postDigest(() => { + const domNode = document.getElementById('apiKeysGridReactRoot'); + + render( + + + , domNode); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + unmountComponentAtNode(domNode); + }); + }); + }, +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx new file mode 100644 index 0000000000000..6bebf17c943a4 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx @@ -0,0 +1,528 @@ +/* + * 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, { Component } from 'react'; +import { + EuiBadge, + EuiButton, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import moment from 'moment-timezone'; +import _ from 'lodash'; +import { toastNotifications } from 'ui/notify'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { SectionLoading } from '../../../../../../../../../src/plugins/es_ui_shared/public/components/section_loading'; +import { ApiKey, ApiKeyToInvalidate } from '../../../../../common/model/api_key'; +import { ApiKeysApi } from '../../../../lib/api_keys_api'; +import { PermissionDenied } from './permission_denied'; +import { EmptyPrompt } from './empty_prompt'; +import { NotEnabled } from './not_enabled'; +import { InvalidateProvider } from './invalidate_provider'; + +interface State { + isLoadingApp: boolean; + isLoadingTable: boolean; + isAdmin: boolean; + areApiKeysEnabled: boolean; + apiKeys: ApiKey[]; + selectedItems: ApiKey[]; + permissionDenied: boolean; + error: any; +} + +const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss'; + +export class ApiKeysGridPage extends Component { + constructor(props: any) { + super(props); + this.state = { + isLoadingApp: true, + isLoadingTable: false, + isAdmin: false, + areApiKeysEnabled: false, + apiKeys: [], + permissionDenied: false, + selectedItems: [], + error: undefined, + }; + } + + public componentDidMount() { + this.checkPrivileges(); + } + + public render() { + const { + permissionDenied, + isLoadingApp, + isLoadingTable, + areApiKeysEnabled, + isAdmin, + error, + apiKeys, + } = this.state; + + if (permissionDenied) { + return ; + } + + if (isLoadingApp) { + return ( + + + + + + ); + } + + if (error) { + const { + body: { error: errorTitle, message, statusCode }, + } = error; + + return ( + + + } + color="danger" + iconType="alert" + > + {statusCode}: {errorTitle} - {message} + + + ); + } + + if (!areApiKeysEnabled) { + return ( + + + + ); + } + + if (!isLoadingTable && apiKeys && apiKeys.length === 0) { + return ( + + + + ); + } + + const description = ( + +

+ {isAdmin ? ( + + ) : ( + + )} +

+
+ ); + + return ( + + + + +

+ +

+
+ {description} +
+
+ + {this.renderTable()} +
+ ); + } + + private renderTable = () => { + const { apiKeys, selectedItems, isLoadingTable, isAdmin } = this.state; + + const message = isLoadingTable ? ( + + ) : ( + undefined + ); + + const sorting = { + sort: { + field: 'expiration', + direction: 'asc', + }, + }; + + const pagination = { + initialPageSize: 20, + pageSizeOptions: [10, 20, 50], + }; + + const selection = { + onSelectionChange: (newSelectedItems: ApiKey[]) => { + this.setState({ + selectedItems: newSelectedItems, + }); + }, + }; + + const search = { + toolsLeft: selectedItems.length ? ( + + {invalidateApiKeyPrompt => { + return ( + + invalidateApiKeyPrompt( + selectedItems.map(({ name, id }) => ({ name, id })), + this.onApiKeysInvalidated + ) + } + color="danger" + data-test-subj="bulkInvalidateActionButton" + > + + + ); + }} + + ) : ( + undefined + ), + toolsRight: ( + this.reloadApiKeys()} + data-test-subj="reloadButton" + > + + + ), + box: { + incremental: true, + }, + filters: isAdmin + ? [ + { + type: 'field_value_selection', + field: 'username', + name: i18n.translate('xpack.security.management.apiKeys.table.userFilterLabel', { + defaultMessage: 'User', + }), + multiSelect: false, + options: Object.keys( + apiKeys.reduce((apiKeysMap: any, apiKey) => { + apiKeysMap[apiKey.username] = true; + return apiKeysMap; + }, {}) + ).map(username => { + return { + value: username, + view: username, + }; + }), + }, + { + type: 'field_value_selection', + field: 'realm', + name: i18n.translate('xpack.security.management.apiKeys.table.realmFilterLabel', { + defaultMessage: 'Realm', + }), + multiSelect: false, + options: Object.keys( + apiKeys.reduce((apiKeysMap: any, apiKey) => { + apiKeysMap[apiKey.realm] = true; + return apiKeysMap; + }, {}) + ).map(realm => { + return { + value: realm, + view: realm, + }; + }), + }, + ] + : undefined, + }; + + return ( + <> + {isAdmin ? ( + <> + + } + color="success" + iconType="user" + size="s" + /> + + + + ) : ( + undefined + )} + + { + { + return { + 'data-test-subj': 'apiKeyRow', + }; + }} + /> + } + + ); + }; + + private getColumnConfig = () => { + const { isAdmin } = this.state; + + let config = [ + { + field: 'name', + name: i18n.translate('xpack.security.management.apiKeys.table.nameColumnName', { + defaultMessage: 'Name', + }), + sortable: true, + }, + ]; + + if (isAdmin) { + config = config.concat([ + { + field: 'username', + name: i18n.translate('xpack.security.management.apiKeys.table.userNameColumnName', { + defaultMessage: 'User', + }), + sortable: true, + }, + { + field: 'realm', + name: i18n.translate('xpack.security.management.apiKeys.table.realmColumnName', { + defaultMessage: 'Realm', + }), + sortable: true, + }, + ]); + } + + config = config.concat([ + { + field: 'creation', + name: i18n.translate('xpack.security.management.apiKeys.table.creationDateColumnName', { + defaultMessage: 'Created', + }), + sortable: true, + // @ts-ignore + render: (creationDateMs: number) => moment(creationDateMs).format(DATE_FORMAT), + }, + { + field: 'expiration', + name: i18n.translate('xpack.security.management.apiKeys.table.expirationDateColumnName', { + defaultMessage: 'Expires', + }), + sortable: true, + // @ts-ignore + render: (expirationDateMs: number) => { + if (expirationDateMs === undefined) { + return ( + + {i18n.translate( + 'xpack.security.management.apiKeys.table.expirationDateNeverMessage', + { + defaultMessage: 'Never', + } + )} + + ); + } + + return moment(expirationDateMs).format(DATE_FORMAT); + }, + }, + { + name: i18n.translate('xpack.security.management.apiKeys.table.statusColumnName', { + defaultMessage: 'Status', + }), + render: ({ expiration }: any) => { + const now = Date.now(); + + if (now > expiration) { + return Expired; + } + + return Active; + }, + }, + { + name: i18n.translate('xpack.security.management.apiKeys.table.actionsColumnName', { + defaultMessage: 'Actions', + }), + actions: [ + { + render: ({ name, id }: any) => { + return ( + + + + {invalidateApiKeyPrompt => { + return ( + + + invalidateApiKeyPrompt([{ id, name }], this.onApiKeysInvalidated) + } + /> + + ); + }} + + + + ); + }, + }, + ], + }, + ]); + + return config; + }; + + private onApiKeysInvalidated = (apiKeysInvalidated: ApiKeyToInvalidate[]): void => { + if (apiKeysInvalidated.length) { + this.reloadApiKeys(); + } + }; + + private async checkPrivileges() { + try { + const { isAdmin, areApiKeysEnabled } = await ApiKeysApi.checkPrivileges(); + this.setState({ isAdmin, areApiKeysEnabled }); + + if (areApiKeysEnabled) { + this.initiallyLoadApiKeys(); + } else { + // We're done loading and will just show the "Disabled" error. + this.setState({ isLoadingApp: false }); + } + } catch (e) { + if (_.get(e, 'body.statusCode') === 403) { + this.setState({ permissionDenied: true, isLoadingApp: false }); + } else { + toastNotifications.addDanger( + this.props.i18n.translate( + 'xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage', + { + defaultMessage: 'Error checking privileges: {message}', + values: { message: _.get(e, 'body.message', '') }, + } + ) + ); + } + } + } + + private initiallyLoadApiKeys = () => { + this.setState({ isLoadingApp: true, isLoadingTable: false }); + this.loadApiKeys(); + }; + + private reloadApiKeys = () => { + this.setState({ apiKeys: [], isLoadingApp: false, isLoadingTable: true }); + this.loadApiKeys(); + }; + + private loadApiKeys = async () => { + try { + const { isAdmin } = this.state; + const { apiKeys } = await ApiKeysApi.getApiKeys(isAdmin); + this.setState({ apiKeys }); + } catch (e) { + this.setState({ error: e }); + } + + this.setState({ isLoadingApp: false, isLoadingTable: false }); + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/empty_prompt.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/empty_prompt.tsx new file mode 100644 index 0000000000000..957ca7010a1a0 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/empty_prompt.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, { Fragment } from 'react'; +import { EuiEmptyPrompt, EuiButton, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { documentationLinks } from '../../services/documentation_links'; + +interface Props { + isAdmin: boolean; +} + +export const EmptyPrompt: React.FunctionComponent = ({ isAdmin }) => ( + + {isAdmin ? ( + + ) : ( + + )} + + } + body={ + +

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

+
+ } + actions={ + + + + } + data-test-subj="emptyPrompt" + /> +); diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/index.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/index.ts new file mode 100644 index 0000000000000..982e34a0ceed5 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/empty_prompt/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 { EmptyPrompt } from './empty_prompt'; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/index.ts new file mode 100644 index 0000000000000..9f4d4239d6b4c --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ApiKeysGridPage } from './api_keys_grid_page'; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/index.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/index.ts new file mode 100644 index 0000000000000..17bfb41fa88b5 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/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 { InvalidateProvider } from './invalidate_provider'; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/invalidate_provider.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/invalidate_provider.tsx new file mode 100644 index 0000000000000..fe9ffc651db29 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/invalidate_provider/invalidate_provider.tsx @@ -0,0 +1,182 @@ +/* + * 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, useRef, useState } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { toastNotifications } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; +import { ApiKeyToInvalidate } from '../../../../../../common/model/api_key'; +import { ApiKeysApi } from '../../../../../lib/api_keys_api'; + +interface Props { + isAdmin: boolean; + children: (invalidateApiKeys: InvalidateApiKeys) => React.ReactElement; +} + +export type InvalidateApiKeys = ( + apiKeys: ApiKeyToInvalidate[], + onSuccess?: OnSuccessCallback +) => void; + +type OnSuccessCallback = (apiKeysInvalidated: ApiKeyToInvalidate[]) => void; + +export const InvalidateProvider: React.FunctionComponent = ({ isAdmin, children }) => { + const [apiKeys, setApiKeys] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const onSuccessCallback = useRef(null); + + const invalidateApiKeyPrompt: InvalidateApiKeys = (keys, onSuccess = () => undefined) => { + if (!keys || !keys.length) { + throw new Error('No API key IDs specified for invalidation'); + } + setIsModalOpen(true); + setApiKeys(keys); + onSuccessCallback.current = onSuccess; + }; + + const closeModal = () => { + setIsModalOpen(false); + setApiKeys([]); + }; + + const invalidateApiKey = async () => { + let result; + let error; + let errors; + + try { + result = await ApiKeysApi.invalidateApiKeys(apiKeys, isAdmin); + } catch (e) { + error = e; + } + + closeModal(); + + if (result) { + const { itemsInvalidated } = result; + ({ errors } = result); + + // Surface success notifications + if (itemsInvalidated && itemsInvalidated.length) { + const hasMultipleSuccesses = itemsInvalidated.length > 1; + const successMessage = hasMultipleSuccesses + ? i18n.translate( + 'xpack.security.management.apiKeys.invalidateApiKey.successMultipleNotificationTitle', + { + defaultMessage: 'Invalidated {count} API keys', + values: { count: itemsInvalidated.length }, + } + ) + : i18n.translate( + 'xpack.security.management.apiKeys.invalidateApiKey.successSingleNotificationTitle', + { + defaultMessage: "Invalidated API key '{name}'", + values: { name: itemsInvalidated[0].name }, + } + ); + toastNotifications.addSuccess(successMessage); + if (onSuccessCallback.current) { + onSuccessCallback.current([...itemsInvalidated]); + } + } + } + + // Surface error notifications + // `error` is generic server error + // `errors` are specific errors with removing particular API keys + if (error || (errors && errors.length)) { + const hasMultipleErrors = (errors && errors.length > 1) || (error && apiKeys.length > 1); + const errorMessage = hasMultipleErrors + ? i18n.translate( + 'xpack.security.management.apiKeys.invalidateApiKey.errorMultipleNotificationTitle', + { + defaultMessage: 'Error deleting {count} apiKeys', + values: { + count: (errors && errors.length) || apiKeys.length, + }, + } + ) + : i18n.translate( + 'xpack.security.management.apiKeys.invalidateApiKey.errorSingleNotificationTitle', + { + defaultMessage: "Error deleting API key '{name}'", + values: { name: (errors && errors[0].name) || apiKeys[0].name }, + } + ); + toastNotifications.addDanger(errorMessage); + } + }; + + const renderModal = () => { + if (!isModalOpen) { + return null; + } + + const isSingle = apiKeys.length === 1; + + return ( + + + {!isSingle ? ( + +

+ {i18n.translate( + 'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription', + { defaultMessage: 'You are about to invalidate these API keys:' } + )} +

+
    + {apiKeys.map(({ name, id }) => ( +
  • {name}
  • + ))} +
+
+ ) : null} +
+
+ ); + }; + + return ( + + {children(invalidateApiKeyPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/index.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/index.ts new file mode 100644 index 0000000000000..faa788342fefa --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/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 { NotEnabled } from './not_enabled'; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/not_enabled.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/not_enabled.tsx new file mode 100644 index 0000000000000..c419e15450c1e --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/not_enabled/not_enabled.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { documentationLinks } from '../../services/documentation_links'; + +export const NotEnabled: React.FunctionComponent = () => ( + + } + color="danger" + iconType="alert" + > + + + + ), + }} + /> + +); diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/index.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/index.ts new file mode 100644 index 0000000000000..8b0bc67f3f777 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/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 { PermissionDenied } from './permission_denied'; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/permission_denied.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/permission_denied.tsx new file mode 100644 index 0000000000000..d406b1684b3ff --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/permission_denied/permission_denied.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiEmptyPrompt, EuiFlexGroup, EuiPageContent } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; + +export const PermissionDenied = () => ( + + + + + + } + body={ +

+ +

+ } + /> +
+
+); diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/services/documentation_links.ts b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/services/documentation_links.ts new file mode 100644 index 0000000000000..1f03763eb542a --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/services/documentation_links.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 { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; + +class DocumentationLinksService { + private esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`; + + public getApiKeyServiceSettingsDocUrl(): string { + return `${this.esDocBasePath}security-settings.html#api-key-service-settings`; + } + + public getCreateApiKeyDocUrl(): string { + return `${this.esDocBasePath}security-api-create-api-key.html`; + } +} + +export const documentationLinks = new DocumentationLinksService(); diff --git a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts index a77c8317c1a2c..7d345ac13dc41 100644 --- a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts +++ b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts @@ -74,3 +74,15 @@ export function getCreateRoleBreadcrumbs() { }, ]; } + +export function getApiKeysBreadcrumbs() { + return [ + MANAGEMENT_BREADCRUMB, + { + text: i18n.translate('xpack.security.apiKeys.breadcrumb', { + defaultMessage: 'API Keys', + }), + href: '#/management/security/api_keys', + }, + ]; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.tsx b/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.tsx index 427fb621d5ddd..282ce4eea160c 100644 --- a/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.tsx +++ b/x-pack/legacy/plugins/security/public/views/management/edit_user/components/edit_user_page.tsx @@ -418,165 +418,159 @@ class EditUserPageUI extends Component { /> ) : null} -
{ - event.preventDefault(); - }} - > - - - - - {isNewUser ? this.passwordFields() : null} - {reserved ? null : ( - - - - - - - - - )} - - + + + + {isNewUser ? this.passwordFields() : null} + {reserved ? null : ( + + { - return { 'data-test-subj': `roleOption-${role.name}`, label: role.name }; + > + + + - - - {isNewUser || showChangePasswordForm ? null : ( - - - - + > + - )} - {this.changePasswordForm()} - - + + )} + + { + return { 'data-test-subj': `roleOption-${role.name}`, label: role.name }; + })} + selectedOptions={selectedRoles} + /> + - {reserved && ( - changeUrl(USERS_PATH)}> + {isNewUser || showChangePasswordForm ? null : ( + + - - )} - {reserved ? null : ( - - - this.saveUser()} - > - {isNewUser ? ( - - ) : ( - - )} - - + + + )} + {this.changePasswordForm()} + + + + {reserved && ( + changeUrl(USERS_PATH)}> + + + )} + {reserved ? null : ( + + + this.saveUser()} + > + {isNewUser ? ( + + ) : ( + + )} + + + + changeUrl(USERS_PATH)} + > + + + + + {isNewUser || reserved ? null : ( changeUrl(USERS_PATH)} + onClick={() => { + this.setState({ showDeleteConfirmation: true }); + }} + data-test-subj="userFormDeleteButton" + color="danger" > - - {isNewUser || reserved ? null : ( - - { - this.setState({ showDeleteConfirmation: true }); - }} - data-test-subj="userFormDeleteButton" - color="danger" - > - - - - )} - - )} - -
+ )} + + )} +
diff --git a/x-pack/legacy/plugins/security/public/views/management/management.js b/x-pack/legacy/plugins/security/public/views/management/management.js index 7cce644553380..8417191b4ee67 100644 --- a/x-pack/legacy/plugins/security/public/views/management/management.js +++ b/x-pack/legacy/plugins/security/public/views/management/management.js @@ -8,12 +8,13 @@ import 'plugins/security/views/management/change_password_form/change_password_f import 'plugins/security/views/management/password_form/password_form'; import 'plugins/security/views/management/users_grid/users'; import 'plugins/security/views/management/roles_grid/roles'; +import 'plugins/security/views/management/api_keys_grid/api_keys'; import 'plugins/security/views/management/edit_user/edit_user'; import 'plugins/security/views/management/edit_role/index'; import routes from 'ui/routes'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; import '../../services/shield_user'; -import { ROLES_PATH, USERS_PATH } from './management_urls'; +import { ROLES_PATH, USERS_PATH, API_KEYS_PATH } from './management_urls'; import { management } from 'ui/management'; import { i18n } from '@kbn/i18n'; @@ -76,6 +77,18 @@ routes.defaults(/^\/management\/security(\/|$)/, { url: `#${ROLES_PATH}`, }); } + + if (!security.hasItem('apiKeys')) { + security.register('apiKeys', { + name: 'securityApiKeysLink', + order: 30, + display: i18n.translate( + 'xpack.security.management.apiKeysTitle', { + defaultMessage: 'API Keys', + }), + url: `#${API_KEYS_PATH}`, + }); + } } if (!showSecurityLinks) { diff --git a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts index 443b2a313aa5e..ea0cba9f7f3a7 100644 --- a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts +++ b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts @@ -11,3 +11,4 @@ export const EDIT_ROLES_PATH = `${ROLES_PATH}/edit`; export const CLONE_ROLES_PATH = `${ROLES_PATH}/clone`; export const USERS_PATH = `${SECURITY_PATH}/users`; export const EDIT_USERS_PATH = `${USERS_PATH}/edit`; +export const API_KEYS_PATH = `${SECURITY_PATH}/api_keys`; diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.test.ts b/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.test.ts index 234667b1a4d3c..6df9d6801e2dc 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.test.ts +++ b/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SpacesPlugin } from '../../../../spaces'; +import { LegacySpacesPlugin } from '../../../../spaces'; import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; @@ -23,7 +23,7 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { getBasePath: jest.fn(), getScopedSpacesClient: jest.fn(), getActiveSpace: jest.fn(), - } as OptionalPlugin; + } as OptionalPlugin; const request = Symbol(); const privilegeOrPrivileges = ['foo', 'bar']; const checkPrivilegesDynamically = checkPrivilegesDynamicallyWithRequestFactory( @@ -45,7 +45,7 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); const mockSpaces = { isEnabled: false, - } as OptionalPlugin; + } as OptionalPlugin; const request = Symbol(); const privilegeOrPrivileges = ['foo', 'bar']; const checkPrivilegesDynamically = checkPrivilegesDynamicallyWithRequestFactory( diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.ts b/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.ts index 537bc07f8e80c..243ad100c5715 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.ts +++ b/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.ts @@ -13,7 +13,7 @@ import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from '. * you may not use this file except in compliance with the Elastic License. */ -import { SpacesPlugin } from '../../../../spaces'; +import { LegacySpacesPlugin } from '../../../../spaces'; import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; export type CheckPrivilegesDynamically = ( @@ -26,7 +26,7 @@ export type CheckPrivilegesDynamicallyWithRequest = ( export function checkPrivilegesDynamicallyWithRequestFactory( checkPrivilegesWithRequest: CheckPrivilegesWithRequest, - spaces: OptionalPlugin + spaces: OptionalPlugin ): CheckPrivilegesDynamicallyWithRequest { return function checkPrivilegesDynamicallyWithRequest(request: Legacy.Request) { const checkPrivileges = checkPrivilegesWithRequest(request); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.test.ts b/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.test.ts index 82bd784a917cd..7fa02330fac97 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.test.ts +++ b/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SpacesPlugin } from '../../../../spaces'; +import { LegacySpacesPlugin } from '../../../../spaces'; import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; @@ -19,7 +19,7 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { const mockSpaces = ({ isEnabled: true, namespaceToSpaceId: jest.fn().mockReturnValue(spaceId), - } as unknown) as OptionalPlugin; + } as unknown) as OptionalPlugin; const request = Symbol(); const privilegeOrPrivileges = ['foo', 'bar']; @@ -50,7 +50,7 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { namespaceToSpaceId: jest.fn().mockImplementation(() => { throw new Error('should not be called'); }), - } as unknown) as OptionalPlugin; + } as unknown) as OptionalPlugin; const request = Symbol(); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.ts b/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.ts index e20a843537541..fb1d258b5a05f 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.ts +++ b/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.ts @@ -5,7 +5,7 @@ */ import { Legacy } from 'kibana'; -import { SpacesPlugin } from '../../../../spaces'; +import { LegacySpacesPlugin } from '../../../../spaces'; import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from './check_privileges'; @@ -19,7 +19,7 @@ export type CheckSavedObjectsPrivileges = ( export const checkSavedObjectsPrivilegesWithRequestFactory = ( checkPrivilegesWithRequest: CheckPrivilegesWithRequest, - spaces: OptionalPlugin + spaces: OptionalPlugin ): CheckSavedObjectsPrivilegesWithRequest => { return function checkSavedObjectsPrivilegesWithRequest(request: Legacy.Request) { return async function checkSavedObjectsPrivileges( diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/service.ts b/x-pack/legacy/plugins/security/server/lib/authorization/service.ts index e939deb64bc09..3d248adb9f8b8 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/service.ts +++ b/x-pack/legacy/plugins/security/server/lib/authorization/service.ts @@ -7,7 +7,7 @@ import { Server } from 'hapi'; import { getClient } from '../../../../../server/lib/get_client_shield'; -import { SpacesPlugin } from '../../../../spaces'; +import { LegacySpacesPlugin } from '../../../../spaces'; import { XPackFeature, XPackMainPlugin } from '../../../../xpack_main/xpack_main'; import { APPLICATION_PREFIX } from '../../../common/constants'; import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; @@ -38,7 +38,7 @@ export function createAuthorizationService( server: Server, securityXPackFeature: XPackFeature, xpackMainPlugin: XPackMainPlugin, - spaces: OptionalPlugin + spaces: OptionalPlugin ): AuthorizationService { const shieldClient = getClient(server); const config = server.config(); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.js new file mode 100644 index 0000000000000..a236badcd0d6b --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.js @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; +import { wrapError } from '../../../../../../../../plugins/security/server'; +import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; + +export function initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn) { + server.route({ + method: 'GET', + path: `${INTERNAL_API_BASE_PATH}/api_key`, + async handler(request) { + try { + const { isAdmin } = request.query; + + const result = await callWithRequest( + request, + 'shield.getAPIKeys', + { + owner: !isAdmin + } + ); + + const validKeys = result.api_keys.filter(({ invalidated }) => !invalidated); + + return { + apiKeys: validKeys, + }; + } catch (error) { + return wrapError(error); + } + }, + config: { + pre: [routePreCheckLicenseFn], + validate: { + query: Joi.object().keys({ + isAdmin: Joi.bool().required(), + }).required(), + }, + } + }); +} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js new file mode 100644 index 0000000000000..ade1f0974096c --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js @@ -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 { getClient } from '../../../../../../../server/lib/get_client_shield'; +import { routePreCheckLicense } from '../../../../lib/route_pre_check_license'; +import { initCheckPrivilegesApi } from './privileges'; +import { initGetApiKeysApi } from './get'; +import { initInvalidateApiKeysApi } from './invalidate'; + +export function initApiKeysApi(server) { + const callWithRequest = getClient(server).callWithRequest; + const routePreCheckLicenseFn = routePreCheckLicense(server); + + const { authorization } = server.plugins.security; + const { application } = authorization; + + initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn, application); + initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn, application); + initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn, application); +} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.js new file mode 100644 index 0000000000000..293142c60be67 --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.js @@ -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 Joi from 'joi'; +import { wrapError } from '../../../../../../../../plugins/security/server'; +import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; + +export function initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn) { + server.route({ + method: 'POST', + path: `${INTERNAL_API_BASE_PATH}/api_key/invalidate`, + async handler(request) { + try { + const { apiKeys, isAdmin } = request.payload; + const itemsInvalidated = []; + const errors = []; + + // Send the request to invalidate the API key and return an error if it could not be deleted. + const sendRequestToInvalidateApiKey = async (id) => { + try { + const body = { id }; + + if (!isAdmin) { + body.owner = true; + } + + await callWithRequest(request, 'shield.invalidateAPIKey', { body }); + return null; + } catch (error) { + return wrapError(error); + } + }; + + const invalidateApiKey = async ({ id, name }) => { + const error = await sendRequestToInvalidateApiKey(id); + if (error) { + errors.push({ id, name, error }); + } else { + itemsInvalidated.push({ id, name }); + } + }; + + // Invalidate all API keys in parallel. + await Promise.all(apiKeys.map((key) => invalidateApiKey(key))); + + return { + itemsInvalidated, + errors, + }; + } catch (error) { + return wrapError(error); + } + }, + config: { + pre: [routePreCheckLicenseFn], + validate: { + payload: Joi.object({ + apiKeys: Joi.array().items(Joi.object({ + id: Joi.string().required(), + name: Joi.string().required(), + })).required(), + isAdmin: Joi.bool().required(), + }) + }, + } + }); +} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.js new file mode 100644 index 0000000000000..3aa30c9a3b9bb --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.js @@ -0,0 +1,75 @@ +/* + * 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 { wrapError } from '../../../../../../../../plugins/security/server'; +import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; + +export function initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn) { + server.route({ + method: 'GET', + path: `${INTERNAL_API_BASE_PATH}/api_key/privileges`, + async handler(request) { + try { + const result = await Promise.all([ + callWithRequest( + request, + 'shield.hasPrivileges', + { + body: { + cluster: [ + 'manage_security', + 'manage_api_key', + ], + }, + } + ), + new Promise(async (resolve, reject) => { + try { + const result = await callWithRequest( + request, + 'shield.getAPIKeys', + { + owner: true + } + ); + // If the API returns a truthy result that means it's enabled. + resolve({ areApiKeysEnabled: !!result }); + } catch (e) { + // This is a brittle dependency upon message. Tracked by https://github.com/elastic/elasticsearch/issues/47759. + if (e.message.includes('api keys are not enabled')) { + return resolve({ areApiKeysEnabled: false }); + } + + // It's a real error, so rethrow it. + reject(e); + } + }), + ]); + + const [{ + cluster: { + manage_security: manageSecurity, + manage_api_key: manageApiKey, + } + }, { + areApiKeysEnabled, + }] = result; + + const isAdmin = manageSecurity || manageApiKey; + + return { + areApiKeysEnabled, + isAdmin, + }; + } catch (error) { + return wrapError(error); + } + }, + config: { + pre: [routePreCheckLicenseFn] + } + }); +} diff --git a/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts b/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts index dc94826c637cc..d043c1587d3c3 100644 --- a/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/common/graphql/shared/schema.gql.ts @@ -53,7 +53,7 @@ export const sharedSchema = gql` source } - enum FlowTargetNew { + enum FlowTargetSourceDest { destination source } diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx index 02bb853a6adf6..0a29845b4f64c 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx @@ -15,16 +15,16 @@ import { start } from '../../../../../../../src/legacy/core_plugins/embeddable_a import { EmbeddablePanel } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; import { Loader } from '../loader'; -import { useIndexPatterns } from '../ml_popover/hooks/use_index_patterns'; +import { useIndexPatterns } from '../../hooks/use_index_patterns'; import { useKibanaUiSetting } from '../../lib/settings/use_kibana_ui_setting'; import { DEFAULT_INDEX_KEY } from '../../../common/constants'; -import { getIndexPatternTitleIdMapping } from '../ml_popover/helpers'; import { IndexPatternsMissingPrompt } from './index_patterns_missing_prompt'; import { MapEmbeddable, SetQuery } from './types'; import * as i18n from './translations'; import { useStateToaster } from '../toasters'; import { createEmbeddable, displayErrorToast, setupEmbeddablesAPI } from './embedded_map_helpers'; import { MapToolTip } from './map_tool_tip/map_tool_tip'; +import { getIndexPatternTitleIdMapping } from '../../hooks/api/helpers'; const EmbeddableWrapper = styled(EuiFlexGroup)` position: relative; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts index 2178bdb9684f3..6c3b6444578d6 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts @@ -71,6 +71,8 @@ export const useAnomaliesTableData = ({ const [anomalyScore] = useKibanaUiSetting(DEFAULT_ANOMALY_SCORE); const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); + const siemJobIds = siemJobs.filter(job => job.isInstalled).map(job => job.id); + useEffect(() => { let isSubscribed = true; const abortCtrl = new AbortController(); @@ -82,11 +84,11 @@ export const useAnomaliesTableData = ({ earliestMs: number, latestMs: number ) { - if (userPermissions && !skip && siemJobs.length > 0) { + if (userPermissions && !skip && siemJobIds.length > 0) { try { const data = await anomaliesTableData( { - jobIds: siemJobs, + jobIds: siemJobIds, criteriaFields: criteriaFieldsInput, aggregationInterval: 'auto', threshold: getThreshold(anomalyScore, threshold), @@ -114,7 +116,7 @@ export const useAnomaliesTableData = ({ } } else if (!userPermissions && isSubscribed) { setLoading(false); - } else if (siemJobs.length === 0 && isSubscribed) { + } else if (siemJobIds.length === 0 && isSubscribed) { setLoading(false); } else if (isSubscribed) { setTableData(null); @@ -134,7 +136,7 @@ export const useAnomaliesTableData = ({ endDate, skip, userPermissions, - siemJobs.join(), + siemJobIds.sort().join(), ]); return [loading, tableData]; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/anomalies_table_data.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/anomalies_table_data.ts index a3781f7b894e7..12c68467fe117 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/anomalies_table_data.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/anomalies_table_data.ts @@ -9,7 +9,7 @@ import chrome from 'ui/chrome'; import { useKibanaUiSetting } from '../../../lib/settings/use_kibana_ui_setting'; import { DEFAULT_KBN_VERSION } from '../../../../common/constants'; import { Anomalies, InfluencerInput, CriteriaFields } from '../types'; -import { throwIfNotOk } from './throw_if_not_ok'; +import { throwIfNotOk } from '../../../hooks/api/api'; export interface Body { jobIds: string[]; criteriaFields: CriteriaFields[]; @@ -25,7 +25,7 @@ export interface Body { export const anomaliesTableData = async ( body: Body, - headers: Record, + headers: Record, signal: AbortSignal ): Promise => { const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/get_ml_capabilities.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/get_ml_capabilities.ts index 9f1af8db5eddc..deec12aacfc42 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/get_ml_capabilities.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/get_ml_capabilities.ts @@ -9,7 +9,7 @@ import chrome from 'ui/chrome'; import { DEFAULT_KBN_VERSION } from '../../../../common/constants'; import { useKibanaUiSetting } from '../../../lib/settings/use_kibana_ui_setting'; import { InfluencerInput, MlCapabilities } from '../types'; -import { throwIfNotOk } from './throw_if_not_ok'; +import { throwIfNotOk } from '../../../hooks/api/api'; export interface Body { jobIds: string[]; @@ -25,7 +25,7 @@ export interface Body { } export const getMlCapabilities = async ( - headers: Record, + headers: Record, signal: AbortSignal ): Promise => { const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts index 9c6493c9c9b5a..374fbd830f920 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.test.ts @@ -6,14 +6,13 @@ import fetchMock from 'fetch-mock'; import { - throwIfNotOk, - parseJsonFromBody, + isMlStartJobError, MessageBody, - tryParseResponse, + parseJsonFromBody, throwIfErrorAttached, - isMlStartJobError, - ToasterErrors, throwIfErrorAttachedToSetup, + ToasterErrors, + tryParseResponse, } from './throw_if_not_ok'; import { SetupMlResponse } from '../../ml_popover/types'; @@ -22,32 +21,6 @@ describe('throw_if_not_ok', () => { fetchMock.reset(); }); - describe('#throwIfNotOk', () => { - test('does a throw if it is given response that is not ok and the body is not parsable', async () => { - fetchMock.mock('http://example.com', 500); - const response = await fetch('http://example.com'); - await expect(throwIfNotOk(response)).rejects.toThrow('Network Error: Internal Server Error'); - }); - - test('does a throw and returns a body if it is parsable', async () => { - fetchMock.mock('http://example.com', { - status: 500, - body: { - statusCode: 500, - message: 'I am a custom message', - }, - }); - const response = await fetch('http://example.com'); - await expect(throwIfNotOk(response)).rejects.toThrow('I am a custom message'); - }); - - test('does NOT do a throw if it is given response is not ok', async () => { - fetchMock.mock('http://example.com', 200); - const response = await fetch('http://example.com'); - await expect(throwIfNotOk(response)).resolves.toEqual(undefined); - }); - }); - describe('#parseJsonFromBody', () => { test('parses a json from the body correctly', async () => { fetchMock.mock('http://example.com', { diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts index b26ee42cf470a..5d28f4b40ab60 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/throw_if_not_ok.ts @@ -35,21 +35,6 @@ export class ToasterErrors extends Error implements ToasterErrorsType { } } -export const throwIfNotOk = async (response: Response): Promise => { - if (!response.ok) { - const body = await parseJsonFromBody(response); - if (body != null && body.message) { - if (body.statusCode != null) { - throw new ToasterErrors([body.message, `${i18n.STATUS_CODE} ${body.statusCode}`]); - } else { - throw new ToasterErrors([body.message]); - } - } else { - throw new ToasterErrors([`${i18n.NETWORK_ERROR} ${response.statusText}`]); - } - } -}; - export const parseJsonFromBody = async (response: Response): Promise => { try { const text = await response.text(); @@ -67,10 +52,13 @@ export const tryParseResponse = (response: string): string => { } }; -export const throwIfErrorAttachedToSetup = (setupResponse: SetupMlResponse): void => { +export const throwIfErrorAttachedToSetup = ( + setupResponse: SetupMlResponse, + jobIdErrorFilter: string[] = [] +): void => { const jobErrors = setupResponse.jobs.reduce( (accum, job) => - job.error != null + job.error != null && jobIdErrorFilter.includes(job.id) ? [ ...accum, job.error.msg, @@ -83,7 +71,7 @@ export const throwIfErrorAttachedToSetup = (setupResponse: SetupMlResponse): voi const dataFeedErrors = setupResponse.datafeeds.reduce( (accum, dataFeed) => - dataFeed.error != null + dataFeed.error != null && jobIdErrorFilter.includes(dataFeed.id.substr('datafeed-'.length)) ? [ ...accum, dataFeed.error.msg, diff --git a/x-pack/legacy/plugins/siem/public/components/ml/api/translations.ts b/x-pack/legacy/plugins/siem/public/components/ml/api/translations.ts index 2bf5a1a54626f..87edc531758fb 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/api/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/api/translations.ts @@ -12,10 +12,3 @@ export const STATUS_CODE = i18n.translate( defaultMessage: 'Status Code:', } ); - -export const NETWORK_ERROR = i18n.translate( - 'xpack.siem.components.ml.api.errors.networkErrorFailureTitle', - { - defaultMessage: 'Network Error:', - } -); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/__mocks__/api.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/__mocks__/api.tsx index 09e8502f33069..76c276cf69b63 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/__mocks__/api.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/__mocks__/api.tsx @@ -4,7 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Group, IndexPatternSavedObject, Job } from '../types'; +import { + Group, + JobSummary, + Module, + RecognizerModule, + SetupMlResponse, + SiemJob, + StartDatafeedResponse, + StopDatafeedResponse, +} from '../types'; export const mockGroupsResponse: Group[] = [ { @@ -22,7 +31,7 @@ export const mockGroupsResponse: Group[] = [ { id: 'suricata', jobIds: ['suricata_alert_rate'], calendarIds: [] }, ]; -export const mockOpenedJob: Job = { +export const mockOpenedJob: JobSummary = { datafeedId: 'datafeed-siem-api-rare_process_linux_ecs', datafeedIndices: ['auditbeat-*'], datafeedState: 'started', @@ -39,7 +48,7 @@ export const mockOpenedJob: Job = { processed_record_count: 3425264, }; -export const mockJobsSummaryResponse: Job[] = [ +export const mockJobsSummaryResponse: JobSummary[] = [ { id: 'rc-rare-process-windows-5', description: @@ -99,44 +108,446 @@ export const mockJobsSummaryResponse: Job[] = [ }, ]; -export const mockConfigTemplates = [ +export const mockGetModuleResponse: Module[] = [ { - name: 'siem_auditbeat_ecs', + id: 'siem_auditbeat', + title: 'SIEM Auditbeat', + description: + 'Detect suspicious network activity and unusual processes in Auditbeat data (beta)', + type: 'Auditbeat data', + logoFile: 'logo.json', defaultIndexPattern: 'auditbeat-*', - jobs: ['siem-api-rare_process_linux_ecs', 'siem-api-suspicious_login_activity_ecs'], + query: { bool: { filter: [{ term: { 'agent.type': 'auditbeat' } }] } }, + jobs: [ + { + id: 'rare_process_by_host_linux_ecs', + config: { + job_type: 'anomaly_detector', + description: 'SIEM Auditbeat: Detect unusually rare processes on Linux (beta)', + groups: ['siem', 'auditbeat', 'process'], + analysis_config: { + bucket_span: '15m', + detectors: [ + { + detector_description: 'rare process executions on Linux', + function: 'rare', + by_field_name: 'process.name', + partition_field_name: 'host.name', + }, + ], + influencers: ['host.name', 'process.name', 'user.name'], + }, + analysis_limits: { model_memory_limit: '256mb' }, + data_description: { time_field: '@timestamp' }, + custom_settings: { + created_by: 'ml-module-siem-auditbeat', + custom_urls: [ + { + url_name: 'Host Details by process name', + url_value: + "siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:'process.name%20:%20%22$process.name$%22',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + { + url_name: 'Host Details by user name', + url_value: + "siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:'user.name%20:%20%22$user.name$%22',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + { + url_name: 'Hosts Overview by process name', + url_value: + "siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:'process.name%20:%20%22$process.name$%22',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + { + url_name: 'Hosts Overview by user name', + url_value: + "siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:'user.name%20:%20%22$user.name$%22',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + ], + }, + }, + }, + ], + datafeeds: [ + { + id: 'datafeed-rare_process_by_host_linux_ecs', + config: { + job_id: 'rare_process_by_host_linux_ecs', + indexes: ['INDEX_PATTERN_NAME'], + query: { + bool: { + filter: [ + { terms: { 'event.action': ['process_started', 'executed'] } }, + { term: { 'agent.type': 'auditbeat' } }, + ], + }, + }, + }, + }, + ], + kibana: {}, }, { - name: 'siem_winlogbeat_ecs', + id: 'siem_winlogbeat', + title: 'SIEM Winlogbeat', + description: 'Detect unusual processes and network activity in Winlogbeat data (beta)', + type: 'Winlogbeat data', + logoFile: 'logo.json', defaultIndexPattern: 'winlogbeat-*', - jobs: ['siem-api-rare_process_windows_ecs'], + query: { bool: { filter: [{ term: { 'agent.type': 'winlogbeat' } }] } }, + jobs: [ + { + id: 'windows_anomalous_network_activity_ecs', + config: { + job_type: 'anomaly_detector', + description: + 'SIEM Winlogbeat: Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity (beta)', + groups: ['siem', 'winlogbeat', 'network'], + analysis_config: { + bucket_span: '15m', + detectors: [ + { + detector_description: 'rare by "process.name"', + function: 'rare', + by_field_name: 'process.name', + }, + ], + influencers: ['host.name', 'process.name', 'user.name', 'destination.ip'], + }, + analysis_limits: { model_memory_limit: '64mb' }, + data_description: { time_field: '@timestamp' }, + custom_settings: { + created_by: 'ml-module-siem-winlogbeat', + custom_urls: [ + { + url_name: 'Host Details by process name', + url_value: + "siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:'process.name%20:%20%22$process.name$%22',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + { + url_name: 'Host Details by user name', + url_value: + "siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:'user.name%20:%20%22$user.name$%22',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + { + url_name: 'Hosts Overview by process name', + url_value: + "siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:'process.name%20:%20%22$process.name$%22',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + { + url_name: 'Hosts Overview by user name', + url_value: + "siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:'user.name%20:%20%22$user.name$%22',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + ], + }, + }, + }, + { + id: 'windows_anomalous_path_activity_ecs', + config: { + job_type: 'anomaly_detector', + groups: ['siem', 'winlogbeat', 'process'], + description: + 'SIEM Winlogbeat: Looks for activity in unusual paths that may indicate execution of malware or persistence mechanisms. Windows payloads often execute from user profile paths (beta)', + analysis_config: { + bucket_span: '15m', + detectors: [ + { + detector_description: 'rare by "process.working_directory"', + function: 'rare', + by_field_name: 'process.working_directory', + }, + ], + influencers: ['host.name', 'process.name', 'user.name'], + }, + analysis_limits: { model_memory_limit: '256mb' }, + data_description: { time_field: '@timestamp' }, + custom_settings: { + created_by: 'ml-module-siem-winlogbeat', + custom_urls: [ + { + url_name: 'Host Details by process name', + url_value: + "siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:'process.name%20:%20%22$process.name$%22',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + { + url_name: 'Host Details by user name', + url_value: + "siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:'user.name%20:%20%22$user.name$%22',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + { + url_name: 'Hosts Overview by process name', + url_value: + "siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:'process.name%20:%20%22$process.name$%22',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + { + url_name: 'Hosts Overview by user name', + url_value: + "siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:'user.name%20:%20%22$user.name$%22',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')),timeline:(linkTo:!(global),timerange:(from:'$earliest$',kind:absolute,to:'$latest$')))", + }, + ], + }, + }, + }, + ], + datafeeds: [ + { + id: 'datafeed-windows_anomalous_path_activity_ecs', + config: { + job_id: 'windows_anomalous_path_activity_ecs', + indices: ['INDEX_PATTERN_NAME'], + query: { + bool: { + filter: [ + { term: { 'event.action': 'Process Create (rule: ProcessCreate)' } }, + { term: { 'agent.type': 'winlogbeat' } }, + ], + }, + }, + }, + }, + { + id: 'datafeed-windows_anomalous_network_activity_ecs', + config: { + job_id: 'windows_anomalous_network_activity_ecs', + indices: ['INDEX_PATTERN_NAME'], + query: { + bool: { + filter: [ + { term: { 'event.action': 'Network connection detected (rule: NetworkConnect)' } }, + { term: { 'agent.type': 'winlogbeat' } }, + ], + must_not: [ + { + bool: { + should: [ + { term: { 'destination.ip': '127.0.0.1' } }, + { term: { 'destination.ip': '127.0.0.53' } }, + { term: { 'destination.ip': '::1' } }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }, + }, + ], + kibana: {}, }, ]; -export const mockInstalledJobIds = ['siem-api-rare_process_linux_ecs']; - -export const mockEmbeddedJobIds = [ - 'siem-api-rare_process_linux_ecs', - 'siem-api-suspicious_login_activity_ecs', - 'siem-api-rare_process_windows_ecs', +export const checkRecognizerSuccess: RecognizerModule[] = [ + { + id: 'siem_auditbeat', + title: 'SIEM Auditbeat', + query: { bool: { filter: [{ term: { 'agent.type': 'auditbeat' } }] } }, + description: + 'Detect suspicious network activity and unusual processes in Auditbeat data (beta)', + logo: { icon: 'securityAnalyticsApp' }, + }, ]; -export const mockIndexPatternSavedObjects: IndexPatternSavedObject[] = [ - { - type: 'index-pattern', - id: '2d1fe420-eeee-11e9-ad95-4b5e687c2aee', - attributes: { - title: 'filebeat-*', +export const mockSetupMlJobAllError: SetupMlResponse = { + jobs: [ + { + id: 'linux_anomalous_network_url_activity_ecs', + success: false, + error: { + msg: + "[resource_already_exists_exception] The job cannot be created with the Id 'linux_anomalous_network_url_activity_ecs'. The Id is already used.", + path: '/_ml/anomaly_detectors/linux_anomalous_network_url_activity_ecs', + query: {}, + body: + '{"job_type":"anomaly_detector","groups":["siem","auditbeat","process"],"description":"SIEM Auditbeat: Looks for an unusual web URL request from a Linux instance. Curl and wget web request activity is very common but unusual web requests from a Linux server can sometimes be malware delivery or execution (beta)","analysis_config":{"bucket_span":"15m","detectors":[{"detector_description":"rare by \\"process.title\\"","function":"rare","by_field_name":"process.title"}],"influencers":["host.name","destination.ip","destination.port"]},"analysis_limits":{"model_memory_limit":"32mb"},"data_description":{"time_field":"@timestamp"},"custom_settings":{"created_by":"ml-module-siem-auditbeat","custom_urls":[{"url_name":"Host Details","url_value":"siem#/ml-hosts/$host.name$?_g=()&timerange=(global:(linkTo:!(timeline),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')),timeline:(linkTo:!(global),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')))"}]},"results_index_name":"linux_anomalous_network_url_activity_ecs"}', + statusCode: 400, + response: + '{"error":{"root_cause":[{"type":"resource_already_exists_exception","reason":"The job cannot be created with the Id \'linux_anomalous_network_url_activity_ecs\'. The Id is already used."}],"type":"resource_already_exists_exception","reason":"The job cannot be created with the Id \'linux_anomalous_network_url_activity_ecs\'. The Id is already used."},"status":400}', + }, + }, + { + id: 'linux_anomalous_network_port_activity_ecs', + success: false, + error: { + msg: + "[resource_already_exists_exception] The job cannot be created with the Id 'linux_anomalous_network_port_activity_ecs'. The Id is already used.", + path: '/_ml/anomaly_detectors/linux_anomalous_network_port_activity_ecs', + query: {}, + body: + '{"job_type":"anomaly_detector","description":"SIEM Auditbeat: Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity (beta)","groups":["siem","auditbeat","process"],"analysis_config":{"bucket_span":"15m","detectors":[{"detector_description":"rare by \\"destination.port\\"","function":"rare","by_field_name":"destination.port"}],"influencers":["host.name","process.name","user.name","destination.ip"]},"analysis_limits":{"model_memory_limit":"32mb"},"data_description":{"time_field":"@timestamp"},"custom_settings":{"created_by":"ml-module-siem-auditbeat","custom_urls":[{"url_name":"Host Details by process name","url_value":"siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:\'process.name%20:%20%22$process.name$%22\',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')),timeline:(linkTo:!(global),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')))"},{"url_name":"Host Details by user name","url_value":"siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:\'user.name%20:%20%22$user.name$%22\',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')),timeline:(linkTo:!(global),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')))"},{"url_name":"Hosts Overview by process name","url_value":"siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:\'process.name%20:%20%22$process.name$%22\',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')),timeline:(linkTo:!(global),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')))"},{"url_name":"Hosts Overview by user name","url_value":"siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:\'user.name%20:%20%22$user.name$%22\',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')),timeline:(linkTo:!(global),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')))"}]},"results_index_name":"linux_anomalous_network_port_activity_ecs"}', + statusCode: 400, + response: + '{"error":{"root_cause":[{"type":"resource_already_exists_exception","reason":"The job cannot be created with the Id \'linux_anomalous_network_port_activity_ecs\'. The Id is already used."}],"type":"resource_already_exists_exception","reason":"The job cannot be created with the Id \'linux_anomalous_network_port_activity_ecs\'. The Id is already used."},"status":400}', + }, + }, + ], + datafeeds: [ + { + id: 'datafeed-linux_anomalous_network_activity_ecs', + success: false, + started: false, + error: { + msg: + '[status_exception] A datafeed [datafeed-linux_anomalous_network_activity_ecs] already exists for job [linux_anomalous_network_activity_ecs]', + path: '/_ml/datafeeds/datafeed-linux_anomalous_network_activity_ecs', + query: {}, + body: + '{"job_id":"linux_anomalous_network_activity_ecs","indices":["auditbeat-*"],"query":{"bool":{"filter":[{"term":{"event.action":"connected-to"}},{"term":{"agent.type":"auditbeat"}}],"must_not":[{"bool":{"should":[{"term":{"destination.ip":"127.0.0.1"}},{"term":{"destination.ip":"127.0.0.53"}},{"term":{"destination.ip":"::1"}}],"minimum_should_match":1}}]}}}', + statusCode: 409, + response: + '{"error":{"root_cause":[{"type":"status_exception","reason":"A datafeed [datafeed-linux_anomalous_network_activity_ecs] already exists for job [linux_anomalous_network_activity_ecs]"}],"type":"status_exception","reason":"A datafeed [datafeed-linux_anomalous_network_activity_ecs] already exists for job [linux_anomalous_network_activity_ecs]"},"status":409}', + }, + }, + { + id: 'datafeed-linux_anomalous_network_port_activity_ecs', + success: false, + started: false, + error: { + msg: + '[status_exception] A datafeed [datafeed-linux_anomalous_network_port_activity_ecs] already exists for job [linux_anomalous_network_port_activity_ecs]', + path: '/_ml/datafeeds/datafeed-linux_anomalous_network_port_activity_ecs', + query: {}, + body: + '{"job_id":"linux_anomalous_network_port_activity_ecs","indices":["auditbeat-*"],"query":{"bool":{"filter":[{"term":{"event.action":"connected-to"}},{"term":{"agent.type":"auditbeat"}}],"must_not":[{"bool":{"should":[{"term":{"destination.ip":"::1"}},{"term":{"destination.ip":"127.0.0.1"}},{"term":{"destination.ip":"::"}},{"term":{"user.name_map.uid":"jenkins"}}],"minimum_should_match":1}}]}}}', + statusCode: 409, + response: + '{"error":{"root_cause":[{"type":"status_exception","reason":"A datafeed [datafeed-linux_anomalous_network_port_activity_ecs] already exists for job [linux_anomalous_network_port_activity_ecs]"}],"type":"status_exception","reason":"A datafeed [datafeed-linux_anomalous_network_port_activity_ecs] already exists for job [linux_anomalous_network_port_activity_ecs]"},"status":409}', + }, + }, + ], + kibana: {}, +}; + +export const mockSetupMlJobSingleErrorSingleSuccess: SetupMlResponse = { + jobs: [ + { + id: 'linux_anomalous_network_activity_ecs', + success: false, + error: { + msg: + "[resource_already_exists_exception] The job cannot be created with the Id 'linux_anomalous_network_activity_ecs'. The Id is already used.", + path: '/_ml/anomaly_detectors/linux_anomalous_network_activity_ecs', + query: {}, + body: + '{"job_type":"anomaly_detector","description":"SIEM Auditbeat: Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity (beta)","groups":["siem","auditbeat","network"],"analysis_config":{"bucket_span":"15m","detectors":[{"detector_description":"rare by \\"process.name\\"","function":"rare","by_field_name":"process.name"}],"influencers":["host.name","process.name","user.name","destination.ip"]},"analysis_limits":{"model_memory_limit":"64mb"},"data_description":{"time_field":"@timestamp"},"custom_settings":{"created_by":"ml-module-siem-auditbeat","custom_urls":[{"url_name":"Host Details by process name","url_value":"siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:\'process.name%20:%20%22$process.name$%22\',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')),timeline:(linkTo:!(global),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')))"},{"url_name":"Host Details by user name","url_value":"siem#/ml-hosts/$host.name$?_g=()&kqlQuery=(filterQuery:(expression:\'user.name%20:%20%22$user.name$%22\',kind:kuery),queryLocation:hosts.details,type:details)&timerange=(global:(linkTo:!(timeline),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')),timeline:(linkTo:!(global),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')))"},{"url_name":"Hosts Overview by process name","url_value":"siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:\'process.name%20:%20%22$process.name$%22\',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')),timeline:(linkTo:!(global),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')))"},{"url_name":"Hosts Overview by user name","url_value":"siem#/ml-hosts?_g=()&kqlQuery=(filterQuery:(expression:\'user.name%20:%20%22$user.name$%22\',kind:kuery),queryLocation:hosts.page,type:page)&timerange=(global:(linkTo:!(timeline),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')),timeline:(linkTo:!(global),timerange:(from:\'$earliest$\',kind:absolute,to:\'$latest$\')))"}]},"results_index_name":"linux_anomalous_network_activity_ecs"}', + statusCode: 400, + response: + '{"error":{"root_cause":[{"type":"resource_already_exists_exception","reason":"The job cannot be created with the Id \'linux_anomalous_network_activity_ecs\'. The Id is already used."}],"type":"resource_already_exists_exception","reason":"The job cannot be created with the Id \'linux_anomalous_network_activity_ecs\'. The Id is already used."},"status":400}', + }, + }, + { id: 'linux_anomalous_network_port_activity_ecs', success: true }, + ], + datafeeds: [ + { + id: 'datafeed-linux_anomalous_network_activity_ecs', + success: false, + started: false, + error: { + msg: + '[status_exception] A datafeed [datafeed-linux_anomalous_network_activity_ecs] already exists for job [linux_anomalous_network_activity_ecs]', + path: '/_ml/datafeeds/datafeed-linux_anomalous_network_activity_ecs', + query: {}, + body: + '{"job_id":"linux_anomalous_network_activity_ecs","indices":["auditbeat-*"],"query":{"bool":{"filter":[{"term":{"event.action":"connected-to"}},{"term":{"agent.type":"auditbeat"}}],"must_not":[{"bool":{"should":[{"term":{"destination.ip":"127.0.0.1"}},{"term":{"destination.ip":"127.0.0.53"}},{"term":{"destination.ip":"::1"}}],"minimum_should_match":1}}]}}}', + statusCode: 409, + response: + '{"error":{"root_cause":[{"type":"status_exception","reason":"A datafeed [datafeed-linux_anomalous_network_activity_ecs] already exists for job [linux_anomalous_network_activity_ecs]"}],"type":"status_exception","reason":"A datafeed [datafeed-linux_anomalous_network_activity_ecs] already exists for job [linux_anomalous_network_activity_ecs]"},"status":409}', + }, + }, + + { id: 'datafeed-linux_anomalous_network_port_activity_ecs', success: true, started: false }, + ], + kibana: {}, +}; + +export const mockSetupMlJobAllSuccess: SetupMlResponse = { + jobs: [ + { + id: 'linux_anomalous_network_activity_ecs', + success: true, }, - updated_at: '2019-08-26T04:30:09.111Z', - version: 'WzE4LLwxXQ==', + { id: 'linux_anomalous_network_port_activity_ecs', success: true }, + ], + datafeeds: [ + { id: 'datafeed-linux_anomalous_network_activity_ecs', success: true, started: false }, + + { id: 'datafeed-linux_anomalous_network_port_activity_ecs', success: true, started: false }, + ], + kibana: {}, +}; + +export const mockStartDatafeedsError: StartDatafeedResponse = { + 'datafeed-linux_anomalous_network_service': { started: false, error: 'Job has no datafeed' }, +}; + +export const mockStartDatafeedsSuccess: StartDatafeedResponse = { + 'datafeed-linux_anomalous_network_service': { started: true }, +}; + +export const mockStopDatafeedsErrorDoesNotExist: StopDatafeedResponse = {}; + +export const mockStopDatafeedsSuccess: StopDatafeedResponse = { + 'datafeed-linux_anomalous_network_service': { stopped: true }, +}; + +export const mockSiemJobs: SiemJob[] = [ + { + id: 'linux_anomalous_network_activity_ecs', + description: + 'SIEM Auditbeat: Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity (beta)', + groups: ['auditbeat', 'process', 'siem'], + processed_record_count: 32010, + memory_status: 'ok', + jobState: 'closed', + hasDatafeed: true, + datafeedId: 'datafeed-linux_anomalous_network_activity_ecs', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'stopped', + latestTimestampMs: 1571022859393, + earliestTimestampMs: 1569812391387, + latestResultsTimestampMs: 1571022900000, + isSingleMetricViewerJob: true, + moduleId: 'siem_auditbeat', + defaultIndexPattern: 'auditbeat-*', + isCompatible: true, + isInstalled: true, + isElasticJob: true, }, { - type: 'index-pattern', - id: '5463ec70-c7ba-ffff-ad95-4b5e687c2aee', - attributes: { - title: 'auditbeat-*', - }, - updated_at: '2019-08-26T04:31:12.934Z', - version: 'WzELLywxXQ==', + id: 'rare_process_by_host_linux_ecs', + description: 'SIEM Auditbeat: Detect unusually rare processes on Linux (beta)', + groups: ['auditbeat', 'process', 'siem'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'closed', + hasDatafeed: true, + datafeedId: 'datafeed-rare_process_by_host_linux_ecs', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'stopped', + isSingleMetricViewerJob: true, + moduleId: 'siem_auditbeat', + defaultIndexPattern: 'auditbeat-*', + isCompatible: true, + isInstalled: true, + isElasticJob: true, + }, + { + datafeedId: '', + datafeedIndices: [], + datafeedState: '', + hasDatafeed: false, + isSingleMetricViewerJob: false, + jobState: '', + memory_status: '', + processed_record_count: 0, + id: 'rare_process_by_host_windows_ecs', + description: 'SIEM Winlogbeat: Detect unusually rare processes on Windows (beta)', + groups: ['process', 'siem', 'winlogbeat'], + defaultIndexPattern: 'winlogbeat-*', + moduleId: 'siem_winlogbeat', + isCompatible: false, + isInstalled: false, + isElasticJob: true, }, ]; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/__snapshots__/popover_description.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/ml_popover/__snapshots__/popover_description.test.tsx.snap index 83dc5446de37e..5eeaee17ee72a 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/__snapshots__/popover_description.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/__snapshots__/popover_description.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FilterGroup renders correctly against snapshot 1`] = ` +exports[`JobsTableFilters renders correctly against snapshot 1`] = ` diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/__snapshots__/upgrade_contents.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/ml_popover/__snapshots__/upgrade_contents.test.tsx.snap index fbad5012f1e5e..cf924f3a06edc 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/__snapshots__/upgrade_contents.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/__snapshots__/upgrade_contents.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FilterGroup renders correctly against snapshot 1`] = ` +exports[`JobsTableFilters renders correctly against snapshot 1`] = ` diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx index ee8298c77f94f..6f74df4aed384 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/api.tsx @@ -6,42 +6,75 @@ import chrome from 'ui/chrome'; import { + CheckRecognizerProps, CloseJobsResponse, - Group, - IndexPatternResponse, - IndexPatternSavedObject, - Job, + ErrorResponse, + GetModulesProps, + JobSummary, MlSetupArgs, + Module, + RecognizerModule, SetupMlResponse, StartDatafeedResponse, StopDatafeedResponse, } from './types'; -import { - throwIfNotOk, - throwIfErrorAttached, - throwIfErrorAttachedToSetup, -} from '../ml/api/throw_if_not_ok'; +import { throwIfErrorAttached, throwIfErrorAttachedToSetup } from '../ml/api/throw_if_not_ok'; import { useKibanaUiSetting } from '../../lib/settings/use_kibana_ui_setting'; import { DEFAULT_KBN_VERSION } from '../../../common/constants'; +import { throwIfNotOk } from '../../hooks/api/api'; -const emptyIndexPattern: IndexPatternSavedObject[] = []; +/** + * Checks the ML Recognizer API to see if a given indexPattern has any compatible modules + * + * @param indexPatternName ES index pattern to check for compatible modules + * @param headers optional headers to add + * @param signal to cancel request + */ +export const checkRecognizer = async ({ + indexPatternName, + headers = {}, + signal, +}: CheckRecognizerProps): Promise => { + const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); + const response = await fetch( + `${chrome.getBasePath()}/api/ml/modules/recognize/${indexPatternName}`, + { + method: 'GET', + credentials: 'same-origin', + headers: { + 'kbn-system-api': 'true', + 'content-type': 'application/json', + 'kbn-version': kbnVersion, + 'kbn-xsrf': kbnVersion, + ...headers, + }, + signal, + } + ); + await throwIfNotOk(response); + return response.json(); +}; /** - * Fetches ML Groups Data + * Returns ML Module for given moduleId. Returns all modules if no moduleId specified * - * @param headers + * @param moduleId id of the module to retrieve + * @param headers optional headers to add optional headers to add + * @param signal to cancel request */ -export const groupsData = async ( - headers: Record, - signal: AbortSignal -): Promise => { +export const getModules = async ({ + moduleId = '', + headers = {}, + signal, +}: GetModulesProps): Promise => { const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); - const response = await fetch(`${chrome.getBasePath()}/api/ml/jobs/groups`, { + const response = await fetch(`${chrome.getBasePath()}/api/ml/modules/get_module/${moduleId}`, { method: 'GET', credentials: 'same-origin', headers: { - 'content-type': 'application/json', 'kbn-system-api': 'true', + 'content-type': 'application/json', + 'kbn-version': kbnVersion, 'kbn-xsrf': kbnVersion, ...headers, }, @@ -56,13 +89,15 @@ export const groupsData = async ( * * @param configTemplate - name of configTemplate to setup * @param indexPatternName - default index pattern configTemplate should be installed with + * @param jobIdErrorFilter - if provided, filters all errors except for given jobIds * @param groups - list of groups to add to jobs being installed * @param prefix - prefix to be added to job name - * @param headers + * @param headers optional headers to add */ export const setupMlJob = async ({ configTemplate, indexPatternName = 'auditbeat-*', + jobIdErrorFilter = [], groups = ['siem'], prefix = '', headers = {}, @@ -81,13 +116,14 @@ export const setupMlJob = async ({ headers: { 'kbn-system-api': 'true', 'content-type': 'application/json', + 'kbn-version': kbnVersion, 'kbn-xsrf': kbnVersion, ...headers, }, }); await throwIfNotOk(response); const json = await response.json(); - throwIfErrorAttachedToSetup(json); + throwIfErrorAttachedToSetup(json, jobIdErrorFilter); return json; }; @@ -96,13 +132,17 @@ export const setupMlJob = async ({ * * @param datafeedIds * @param start - * @param headers + * @param headers optional headers to add */ -export const startDatafeeds = async ( - datafeedIds: string[], - headers: Record, - start = 0 -): Promise => { +export const startDatafeeds = async ({ + datafeedIds, + headers, + start = 0, +}: { + datafeedIds: string[]; + start: number; + headers?: Record; +}): Promise => { const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); const response = await fetch(`${chrome.getBasePath()}/api/ml/jobs/force_start_datafeeds`, { method: 'POST', @@ -114,6 +154,7 @@ export const startDatafeeds = async ( headers: { 'kbn-system-api': 'true', 'content-type': 'application/json', + 'kbn-version': kbnVersion, 'kbn-xsrf': kbnVersion, ...headers, }, @@ -128,12 +169,15 @@ export const startDatafeeds = async ( * Stops the given dataFeedIds and sets the corresponding Job's jobState to closed * * @param datafeedIds - * @param headers + * @param headers optional headers to add */ -export const stopDatafeeds = async ( - datafeedIds: string[], - headers: Record -): Promise<[StopDatafeedResponse, CloseJobsResponse]> => { +export const stopDatafeeds = async ({ + datafeedIds, + headers, +}: { + datafeedIds: string[]; + headers?: Record; +}): Promise<[StopDatafeedResponse | ErrorResponse, CloseJobsResponse]> => { const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); const stopDatafeedsResponse = await fetch(`${chrome.getBasePath()}/api/ml/jobs/stop_datafeeds`, { method: 'POST', @@ -176,63 +220,27 @@ export const stopDatafeeds = async ( }; /** - * Fetches Job Details for given jobIds + * Fetches a summary of all ML jobs currently installed * - * @param jobIds - * @param headers + * NOTE: If not sending jobIds in the body, you must at least send an empty body or the server will + * return a 500 + * + * @param signal to cancel request */ -export const jobsSummary = async ( - jobIds: string[], - headers: Record, - signal: AbortSignal -): Promise => { +export const getJobsSummary = async (signal: AbortSignal): Promise => { const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); const response = await fetch(`${chrome.getBasePath()}/api/ml/jobs/jobs_summary`, { method: 'POST', credentials: 'same-origin', - body: JSON.stringify({ jobIds }), + body: JSON.stringify({}), headers: { 'content-type': 'application/json', - 'kbn-xsrf': kbnVersion, 'kbn-system-api': 'true', - ...headers, + 'kbn-version': kbnVersion, + 'kbn-xsrf': kbnVersion, }, signal, }); await throwIfNotOk(response); return response.json(); }; - -/** - * Fetches Configured Index Patterns from the Kibana saved objects API (as ML does during create job flow) - * TODO: Used by more than just ML now -- refactor to shared component https://github.com/elastic/siem-team/issues/448 - * @param headers - */ -export const getIndexPatterns = async ( - headers: Record, - signal: AbortSignal -): Promise => { - const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); - const response = await fetch( - `${chrome.getBasePath()}/api/saved_objects/_find?type=index-pattern&fields=title&fields=type&per_page=10000`, - { - method: 'GET', - credentials: 'same-origin', - headers: { - 'content-type': 'application/json', - 'kbn-xsrf': kbnVersion, - 'kbn-system-api': 'true', - ...headers, - }, - signal, - } - ); - await throwIfNotOk(response); - const results: IndexPatternResponse = await response.json(); - - if (results.saved_objects && Array.isArray(results.saved_objects)) { - return results.saved_objects; - } else { - return emptyIndexPattern; - } -}; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/config_templates.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/config_templates.tsx deleted file mode 100644 index b6391668762d1..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/config_templates.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ConfigTemplate } from './types'; - -/** - * Config Templates w/ corresponding defaultIndexPattern and jobId's of the SIEM Jobs embedded - * in ML. Added as part of: https://github.com/elastic/kibana/pull/39678/files - */ -export const configTemplates: ConfigTemplate[] = [ - { - name: 'siem_auditbeat_ecs', - defaultIndexPattern: 'auditbeat-*', - jobs: [ - 'rare_process_by_host_linux_ecs', - 'suspicious_login_activity_ecs', - 'linux_anomalous_network_activity_ecs', - 'linux_anomalous_network_port_activity_ecs', - 'linux_anomalous_network_service', - 'linux_anomalous_network_url_activity_ecs', - 'linux_anomalous_process_all_hosts_ecs', - 'linux_anomalous_user_name_ecs', - ], - }, - { - name: 'siem_winlogbeat_ecs', - defaultIndexPattern: 'winlogbeat-*', - jobs: [ - 'rare_process_by_host_windows_ecs', - 'windows_anomalous_network_activity_ecs', - 'windows_anomalous_path_activity_ecs', - 'windows_anomalous_process_all_hosts_ecs', - 'windows_anomalous_process_creation', - 'windows_anomalous_script', - 'windows_anomalous_service', - 'windows_anomalous_user_name_ecs', - ], - }, -]; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/helpers.test.tsx index a7025c68c429e..26ebfeb91629b 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/helpers.test.tsx @@ -4,153 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - mockConfigTemplates, - mockEmbeddedJobIds, - mockIndexPatternSavedObjects, - mockInstalledJobIds, - mockJobsSummaryResponse, -} from './__mocks__/api'; -import { - getConfigTemplatesToInstall, - getIndexPatternTitleIdMapping, - getIndexPatternTitles, - getJobsToDisplay, - getJobsToInstall, - searchFilter, - getStablePatternTitles, -} from './helpers'; +import { mockSiemJobs } from './__mocks__/api'; +import { filterJobs, getStablePatternTitles, searchFilter } from './helpers'; jest.mock('../ml/permissions/has_ml_admin_permissions', () => ({ hasMlAdminPermissions: () => true, })); describe('helpers', () => { - describe('getJobsToInstall', () => { - test('returns jobIds from all ConfigTemplates', () => { - const jobsToInstall = getJobsToInstall(mockConfigTemplates); - expect(jobsToInstall.length).toEqual(3); - }); - }); - - describe('getConfigTemplatesToInstall', () => { - test('returns all configTemplates if no jobs are installed', () => { - const configTemplatesToInstall = getConfigTemplatesToInstall( - mockConfigTemplates, - [], - ['auditbeat-*', 'winlogbeat-*'] - ); - expect(configTemplatesToInstall.length).toEqual(2); - }); - - test('returns subset of configTemplates if index not available', () => { - const configTemplatesToInstall = getConfigTemplatesToInstall( - mockConfigTemplates, - [], - ['auditbeat-*', 'spongbeat-*'] - ); - expect(configTemplatesToInstall.length).toEqual(1); - }); - - test('returns all configTemplates if only partial jobs installed', () => { - const configTemplatesToInstall = getConfigTemplatesToInstall( - mockConfigTemplates, - mockInstalledJobIds, - ['auditbeat-*', 'winlogbeat-*'] - ); - expect(configTemplatesToInstall.length).toEqual(2); - }); - - test('returns no configTemplates if index is substring of indexPatterns', () => { - const configTemplatesToInstall = getConfigTemplatesToInstall( - mockConfigTemplates, - mockInstalledJobIds, - ['winlogbeat-**'] - ); - expect(configTemplatesToInstall.length).toEqual(0); - }); - }); - - describe('getJobsToDisplay', () => { - test('returns empty array when null summaryData provided', () => { - const jobsToDisplay = getJobsToDisplay(null, mockEmbeddedJobIds, false, false); - expect(jobsToDisplay.length).toEqual(0); - }); - - test('returns all DisplayJobs', () => { - const jobsToDisplay = getJobsToDisplay( - mockJobsSummaryResponse, - mockEmbeddedJobIds, - false, - false - ); - expect(jobsToDisplay.length).toEqual(4); - }); - - test('returns DisplayJobs matching only embeddedJobs', () => { - const jobsToDisplay = getJobsToDisplay( - mockJobsSummaryResponse, - mockEmbeddedJobIds, - true, - false - ); - expect(jobsToDisplay.length).toEqual(3); - }); - - test('returns only custom DisplayJobs from jobsSummary', () => { - const jobsToDisplay = getJobsToDisplay( - mockJobsSummaryResponse, - mockEmbeddedJobIds, - false, - true - ); - expect(jobsToDisplay.length).toEqual(1); + describe('filterJobs', () => { + test('returns all jobs when no filter is suplied', () => { + const filteredJobs = filterJobs({ + jobs: mockSiemJobs, + selectedGroups: [], + showCustomJobs: false, + showElasticJobs: false, + filterQuery: '', + }); + expect(filteredJobs.length).toEqual(3); }); }); describe('searchFilter', () => { test('returns all jobs when nullfilterQuery is provided', () => { - const jobsToDisplay = searchFilter(mockJobsSummaryResponse); - expect(jobsToDisplay.length).toEqual(mockJobsSummaryResponse.length); + const jobsToDisplay = searchFilter(mockSiemJobs); + expect(jobsToDisplay.length).toEqual(mockSiemJobs.length); }); test('returns correct DisplayJobs when filterQuery matches job.id', () => { - const jobsToDisplay = searchFilter(mockJobsSummaryResponse, 'rare'); - expect(jobsToDisplay.length).toEqual(3); + const jobsToDisplay = searchFilter(mockSiemJobs, 'rare_process'); + expect(jobsToDisplay.length).toEqual(2); }); test('returns correct DisplayJobs when filterQuery matches job.description', () => { - const jobsToDisplay = searchFilter(mockJobsSummaryResponse, 'high number'); - expect(jobsToDisplay.length).toEqual(1); - }); - }); - - describe('getIndexPatternTitles', () => { - test('returns empty array when no index patterns are provided', () => { - const indexPatternTitles = getIndexPatternTitles([]); - expect(indexPatternTitles.length).toEqual(0); - }); - - test('returns titles when index patterns are provided', () => { - const indexPatternTitles = getIndexPatternTitles(mockIndexPatternSavedObjects); - expect(indexPatternTitles.length).toEqual(2); - }); - }); - - describe('getIndexPatternTitleIdMapping', () => { - test('returns empty array when no index patterns are provided', () => { - const indexPatternTitleIdMapping = getIndexPatternTitleIdMapping([]); - expect(indexPatternTitleIdMapping.length).toEqual(0); - }); - - test('returns correct mapping when index patterns are provided', () => { - const indexPatternTitleIdMapping = getIndexPatternTitleIdMapping( - mockIndexPatternSavedObjects - ); - expect(indexPatternTitleIdMapping).toEqual([ - { id: '2d1fe420-eeee-11e9-ad95-4b5e687c2aee', title: 'filebeat-*' }, - { id: '5463ec70-c7ba-ffff-ad95-4b5e687c2aee', title: 'auditbeat-*' }, - ]); + const jobsToDisplay = searchFilter(mockSiemJobs, 'Detect unusually'); + expect(jobsToDisplay.length).toEqual(2); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/helpers.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/helpers.tsx index d0fc3ab074ebf..eaca7a9068c37 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/helpers.tsx @@ -4,56 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ConfigTemplate, IndexPatternSavedObject, Job } from './types'; +import { SiemJob } from './types'; /** - * Returns all `jobIds` for each configTemplate provided - * - * @param templates ConfigTemplates as provided by ML Team (https://github.com/elastic/machine-learning-data/issues/194#issuecomment-505779406) - */ -export const getJobsToInstall = (templates: ConfigTemplate[]): string[] => - templates.reduce((jobs: string[], template) => [...jobs, ...template.jobs], []); - -/** - * Returns which ConfigTemplates that need to be installed based off of which Jobs are already installed and the configured indexPattern - * - * @param templates ConfigTemplates as provided by ML Team - * @param installedJobIds list of installed JobIds - * @param indexPatterns list of the user's currently configured IndexPatterns - */ -export const getConfigTemplatesToInstall = ( - templates: ConfigTemplate[], - installedJobIds: string[], - indexPatterns: string[] -): ConfigTemplate[] => - templates - .filter(ct => !ct.jobs.every(ctJobId => installedJobIds.includes(ctJobId))) - .filter(ct => indexPatterns.includes(ct.defaultIndexPattern)); - -/** - * Returns a filtered array of Jobs that based on filterGroup selection (Elastic vs Custom Jobs) and any user provided filterQuery + * Returns a filtered array of Jobs according to JobsTableFilters selections * * @param jobs to filter - * @param embeddedJobIds jobIds as defined in the ConfigTemplates provided by the ML Team + * @param selectedGroups groups to filter on * @param showCustomJobs whether or not to show all Custom Jobs (Non-embedded Jobs in SIEM Group) * @param showElasticJobs whether or not to show Elastic Jobs (Embedded ConfigTemplate Jobs) * @param filterQuery user-provided search string to filter for occurrence in job names/description */ -export const getJobsToDisplay = ( - jobs: Job[] | null, - embeddedJobIds: string[], - showCustomJobs: boolean, - showElasticJobs: boolean, - filterQuery?: string -): Job[] => - jobs - ? searchFilter( - jobs - .filter(job => (showCustomJobs ? embeddedJobIds.includes(job.id) : true)) - .filter(job => (showElasticJobs ? !embeddedJobIds.includes(job.id) : true)), - filterQuery - ) - : []; +export const filterJobs = ({ + jobs, + selectedGroups, + showCustomJobs, + showElasticJobs, + filterQuery, +}: { + jobs: SiemJob[]; + selectedGroups: string[]; + showCustomJobs: boolean; + showElasticJobs: boolean; + filterQuery: string; +}): SiemJob[] => + searchFilter( + jobs + .filter(job => !showCustomJobs || (showCustomJobs && !job.isElasticJob)) + .filter(job => !showElasticJobs || (showElasticJobs && job.isElasticJob)) + .filter( + job => selectedGroups.length === 0 || selectedGroups.some(g => job.groups.includes(g)) + ), + filterQuery + ); /** * Returns filtered array of Jobs based on user-provided search string to filter for occurrence in job names/description @@ -61,21 +44,13 @@ export const getJobsToDisplay = ( * @param jobs to filter * @param filterQuery user-provided search string to filter for occurrence in job names/description */ -export const searchFilter = (jobs: Job[], filterQuery?: string): Job[] => +export const searchFilter = (jobs: SiemJob[], filterQuery?: string): SiemJob[] => jobs.filter(job => filterQuery == null ? true : job.id.includes(filterQuery) || job.description.includes(filterQuery) ); -/** - * Returns a string array of Index Pattern Titles - * - * @param indexPatterns IndexPatternSavedObject[] as provided from the useIndexPatterns() hook - */ -export const getIndexPatternTitles = (indexPatterns: IndexPatternSavedObject[]): string[] => - indexPatterns.reduce((acc: string[], v) => [...acc, v.attributes.title], []); - /** * Given an array of titles this will always return the same string for usage within * useEffect and other shallow compare areas. @@ -83,19 +58,3 @@ export const getIndexPatternTitles = (indexPatterns: IndexPatternSavedObject[]): * @param patterns string[] string array that will return a stable reference regardless of ordering or case sensitivity. */ export const getStablePatternTitles = (patterns: string[]) => patterns.sort().join(); - -/** - * Returns a mapping of indexPatternTitle to indexPatternId - * - * @param indexPatterns IndexPatternSavedObject[] as provided from the useIndexPatterns() hook - */ -export const getIndexPatternTitleIdMapping = ( - indexPatterns: IndexPatternSavedObject[] -): Array<{ title: string; id: string }> => - indexPatterns.reduce((acc: Array<{ title: string; id: string }>, v) => { - if (v.attributes && v.attributes.title) { - return [...acc, { title: v.attributes.title, id: v.id }]; - } else { - return acc; - } - }, []); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/translations.ts b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/translations.ts index 3982931a2b7ca..4740dbb59e352 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/translations.ts @@ -6,20 +6,6 @@ import { i18n } from '@kbn/i18n'; -export const INDEX_PATTERN_FETCH_FAILURE = i18n.translate( - 'xpack.siem.components.mlPopup.hooks.errors.indexPatternFetchFailureTitle', - { - defaultMessage: 'Index pattern fetch failure', - } -); - -export const JOB_SUMMARY_FETCH_FAILURE = i18n.translate( - 'xpack.siem.components.mlPopup.hooks.errors.jobSummaryFetchFailureTitle', - { - defaultMessage: 'Job summary fetch failure', - } -); - export const SIEM_JOB_FETCH_FAILURE = i18n.translate( 'xpack.siem.components.mlPopup.hooks.errors.siemJobFetchFailureTitle', { diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_job_summary_data.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_job_summary_data.test.tsx deleted file mode 100644 index ad863369199a8..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_job_summary_data.test.tsx +++ /dev/null @@ -1,17 +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 { getSiemJobsFromJobsSummary } from './use_job_summary_data'; -import { mockJobsSummaryResponse } from '../__mocks__/api'; - -describe('useJobSummaryData', () => { - describe('getSiemJobsFromJobsSummary', () => { - test('returns all jobs that are in the siem group', () => { - const siemJobs = getSiemJobsFromJobsSummary(mockJobsSummaryResponse); - expect(siemJobs.length).toEqual(3); - }); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_job_summary_data.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_job_summary_data.tsx deleted file mode 100644 index 6ae18bc15ab9c..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_job_summary_data.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useContext, useEffect, useState } from 'react'; - -import { jobsSummary } from '../api'; -import { Job } from '../types'; -import { hasMlUserPermissions } from '../../ml/permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../../ml/permissions/ml_capabilities_provider'; -import { useStateToaster } from '../../toasters'; -import { errorToToaster } from '../../ml/api/error_to_toaster'; -import { useKibanaUiSetting } from '../../../lib/settings/use_kibana_ui_setting'; -import { DEFAULT_KBN_VERSION } from '../../../../common/constants'; - -import * as i18n from './translations'; - -type Return = [boolean, Job[] | null]; - -export const getSiemJobsFromJobsSummary = (data: Job[]) => - data.reduce((jobs: Job[], job: Job) => { - return job.groups.includes('siem') ? [...jobs, job] : jobs; - }, []); - -export const useJobSummaryData = (jobIds: string[] = [], refreshToggle = false): Return => { - const [jobSummaryData, setJobSummaryData] = useState(null); - const [loading, setLoading] = useState(true); - const capabilities = useContext(MlCapabilitiesContext); - const userPermissions = hasMlUserPermissions(capabilities); - const [, dispatchToaster] = useStateToaster(); - const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); - - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); - setLoading(true); - - async function fetchSiemJobsFromJobsSummary() { - if (userPermissions) { - try { - const data: Job[] = await jobsSummary( - jobIds, - { - 'kbn-version': kbnVersion, - }, - abortCtrl.signal - ); - - // TODO: API returns all jobs even though we specified jobIds -- jobsSummary call seems to match request in ML App? - const siemJobs = getSiemJobsFromJobsSummary(data); - if (isSubscribed) { - setJobSummaryData(siemJobs); - } - } catch (error) { - if (isSubscribed) { - errorToToaster({ title: i18n.JOB_SUMMARY_FETCH_FAILURE, error, dispatchToaster }); - } - } - } - if (isSubscribed) { - setLoading(false); - } - } - - fetchSiemJobsFromJobsSummary(); - return () => { - isSubscribed = false; - abortCtrl.abort(); - }; - }, [refreshToggle, userPermissions]); - - return [loading, jobSummaryData]; -}; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.test.tsx deleted file mode 100644 index e1f5a346d092e..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.test.tsx +++ /dev/null @@ -1,17 +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 { mockGroupsResponse } from '../__mocks__/api'; -import { getSiemJobIdsFromGroupsData } from './use_siem_jobs'; - -describe('useSiemJobs', () => { - describe('getSiemJobsFromGroupData', () => { - test('returns all jobIds for siem group', () => { - const siemJobIds = getSiemJobIdsFromGroupsData(mockGroupsResponse); - expect(siemJobIds.length).toEqual(6); - }); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx index f7f45d67d3468..89f995773e833 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx @@ -4,33 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState, useEffect, useContext } from 'react'; +import { useContext, useEffect, useState } from 'react'; -import { groupsData } from '../api'; -import { Group } from '.././types'; +import { checkRecognizer, getJobsSummary, getModules } from '../api'; +import { SiemJob } from '../types'; import { hasMlUserPermissions } from '../../ml/permissions/has_ml_user_permissions'; import { MlCapabilitiesContext } from '../../ml/permissions/ml_capabilities_provider'; import { useStateToaster } from '../../toasters'; import { errorToToaster } from '../../ml/api/error_to_toaster'; import { useKibanaUiSetting } from '../../../lib/settings/use_kibana_ui_setting'; -import { DEFAULT_KBN_VERSION } from '../../../../common/constants'; +import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; import * as i18n from './translations'; +import { createSiemJobs } from './use_siem_jobs_helpers'; -type Return = [boolean, string[]]; - -export const getSiemJobIdsFromGroupsData = (data: Group[]) => - data.reduce((jobIds: string[], group: Group) => { - return group.id === 'siem' ? [...jobIds, ...group.jobIds] : jobIds; - }, []); +type Return = [boolean, SiemJob[]]; +/** + * Compiles a collection of SiemJobs, which are a list of all jobs relevant to the SIEM App. This + * includes all installed jobs in the `SIEM` ML group, and all jobs within ML Modules defined in + * ml_module (whether installed or not). Use the corresponding helper functions to filter the job + * list as necessary. E.g. installed jobs, running jobs, etc. + * + * @param refetchData + */ export const useSiemJobs = (refetchData: boolean): Return => { - const [siemJobs, setSiemJobs] = useState([]); + const [siemJobs, setSiemJobs] = useState([]); const [loading, setLoading] = useState(true); const capabilities = useContext(MlCapabilitiesContext); const userPermissions = hasMlUserPermissions(capabilities); + const [siemDefaultIndex] = useKibanaUiSetting(DEFAULT_INDEX_KEY); const [, dispatchToaster] = useStateToaster(); - const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); useEffect(() => { let isSubscribed = true; @@ -40,16 +44,17 @@ export const useSiemJobs = (refetchData: boolean): Return => { async function fetchSiemJobIdsFromGroupsData() { if (userPermissions) { try { - const data = await groupsData( - { - 'kbn-version': kbnVersion, - }, - abortCtrl.signal - ); + // Batch fetch all installed jobs, ML modules, and check which modules are compatible with siemDefaultIndex + const [jobSummaryData, modulesData, compatibleModules] = await Promise.all([ + getJobsSummary(abortCtrl.signal), + getModules({ signal: abortCtrl.signal }), + checkRecognizer({ indexPatternName: siemDefaultIndex, signal: abortCtrl.signal }), + ]); + + const compositeSiemJobs = createSiemJobs(jobSummaryData, modulesData, compatibleModules); - const siemJobIds = getSiemJobIdsFromGroupsData(data); if (isSubscribed) { - setSiemJobs(siemJobIds); + setSiemJobs(compositeSiemJobs); } } catch (error) { if (isSubscribed) { diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs_helpers.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs_helpers.test.tsx new file mode 100644 index 0000000000000..fc9f369a305aa --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs_helpers.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 { + composeModuleAndInstalledJobs, + createSiemJobs, + getAugmentedFields, + getInstalledJobs, + getModuleJobs, + moduleToSiemJob, +} from './use_siem_jobs_helpers'; +import { + checkRecognizerSuccess, + mockGetModuleResponse, + mockJobsSummaryResponse, +} from '../__mocks__/api'; + +// TODO: Expand test coverage + +describe('useSiemJobsHelpers', () => { + describe('moduleToSiemJob', () => { + test('correctly converts module to SiemJob', () => { + const siemJob = moduleToSiemJob( + mockGetModuleResponse[0], + mockGetModuleResponse[0].jobs[0], + false + ); + expect(siemJob).toEqual({ + datafeedId: '', + datafeedIndices: [], + datafeedState: '', + defaultIndexPattern: 'auditbeat-*', + description: 'SIEM Auditbeat: Detect unusually rare processes on Linux (beta)', + groups: ['auditbeat', 'process', 'siem'], + hasDatafeed: false, + id: 'rare_process_by_host_linux_ecs', + isCompatible: false, + isElasticJob: true, + isInstalled: false, + isSingleMetricViewerJob: false, + jobState: '', + memory_status: '', + moduleId: 'siem_auditbeat', + processed_record_count: 0, + }); + }); + + describe('getAugmentedFields', () => { + test('return correct augmented fields for given matching compatible modules', () => { + const moduleJobs = getModuleJobs(mockGetModuleResponse, ['siem_auditbeat']); + const augmentedFields = getAugmentedFields('rare_process_by_host_linux_ecs', moduleJobs, [ + 'siem_auditbeat', + ]); + expect(augmentedFields).toEqual({ + defaultIndexPattern: 'auditbeat-*', + isCompatible: true, + isElasticJob: true, + moduleId: 'siem_auditbeat', + }); + }); + }); + + describe('getModuleJobs', () => { + test('returns all jobs within a module for a compatible moduleId', () => { + const moduleJobs = getModuleJobs(mockGetModuleResponse, ['siem_auditbeat']); + expect(moduleJobs.length).toEqual(3); + }); + }); + + describe('getInstalledJobs', () => { + test('returns all jobs from jobSummary for a compatible moduleId', () => { + const moduleJobs = getModuleJobs(mockGetModuleResponse, ['siem_auditbeat']); + const installedJobs = getInstalledJobs(mockJobsSummaryResponse, moduleJobs, [ + 'siem_auditbeat', + ]); + expect(installedJobs.length).toEqual(3); + }); + }); + + describe('composeModuleAndInstalledJobs', () => { + test('returns correct number of jobs when composing separate module and installed jobs', () => { + const moduleJobs = getModuleJobs(mockGetModuleResponse, ['siem_auditbeat']); + const installedJobs = getInstalledJobs(mockJobsSummaryResponse, moduleJobs, [ + 'siem_auditbeat', + ]); + const siemJobs = composeModuleAndInstalledJobs(installedJobs, moduleJobs); + expect(siemJobs.length).toEqual(6); + }); + }); + + describe('createSiemJobs', () => { + test('returns correct number of jobs when creating jobs with successful responses', () => { + const siemJobs = createSiemJobs( + mockJobsSummaryResponse, + mockGetModuleResponse, + checkRecognizerSuccess + ); + expect(siemJobs.length).toEqual(6); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs_helpers.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs_helpers.tsx new file mode 100644 index 0000000000000..81b7914b81742 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs_helpers.tsx @@ -0,0 +1,154 @@ +/* + * 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 { + AugmentedSiemJobFields, + JobSummary, + Module, + ModuleJob, + RecognizerModule, + SiemJob, +} from '../types'; +import { mlModules } from '../ml_modules'; + +/** + * Helper function for converting from ModuleJob -> SiemJob + * @param module + * @param moduleJob + * @param isCompatible + */ +export const moduleToSiemJob = ( + module: Module, + moduleJob: ModuleJob, + isCompatible: boolean +): SiemJob => { + return { + datafeedId: '', + datafeedIndices: [], + datafeedState: '', + hasDatafeed: false, + isSingleMetricViewerJob: false, + jobState: '', + memory_status: '', + processed_record_count: 0, + id: moduleJob.id, + description: moduleJob.config.description, + groups: [...moduleJob.config.groups].sort(), + defaultIndexPattern: module.defaultIndexPattern, + moduleId: module.id, + isCompatible, + isInstalled: false, + isElasticJob: true, + }; +}; + +/** + * Returns fields necessary to augment a ModuleJob to a SiemJob + * + * @param jobId + * @param moduleJobs + * @param compatibleModuleIds + */ +export const getAugmentedFields = ( + jobId: string, + moduleJobs: SiemJob[], + compatibleModuleIds: string[] +): AugmentedSiemJobFields => { + const moduleJob = moduleJobs.find(mj => mj.id === jobId); + return moduleJob !== undefined + ? { + moduleId: moduleJob.moduleId, + defaultIndexPattern: moduleJob.defaultIndexPattern, + isCompatible: compatibleModuleIds.includes(moduleJob.moduleId), + isElasticJob: true, + } + : { + moduleId: '', + defaultIndexPattern: '', + isCompatible: true, + isElasticJob: false, + }; +}; + +/** + * Process Modules[] from the `get_module` ML API into SiemJobs[] by filtering to SIEM specific + * modules and unpacking jobs from each module + * + * @param modulesData + * @param compatibleModuleIds + */ +export const getModuleJobs = (modulesData: Module[], compatibleModuleIds: string[]): SiemJob[] => + modulesData + .filter(module => mlModules.includes(module.id)) + .map(module => [ + ...module.jobs.map(moduleJob => + moduleToSiemJob(module, moduleJob, compatibleModuleIds.includes(module.id)) + ), + ]) + .flat(); + +/** + * Process JobSummary[] from the `jobs_summary` ML API into SiemJobs[] by filtering to to SIEM jobs + * and augmenting with moduleId/defaultIndexPattern/isCompatible + * + * @param jobSummaryData + * @param moduleJobs + * @param compatibleModuleIds + */ +export const getInstalledJobs = ( + jobSummaryData: JobSummary[], + moduleJobs: SiemJob[], + compatibleModuleIds: string[] +): SiemJob[] => + jobSummaryData + .filter(({ groups }) => groups.includes('siem')) + .map(jobSummary => ({ + ...jobSummary, + ...getAugmentedFields(jobSummary.id, moduleJobs, compatibleModuleIds), + isInstalled: true, + })); + +/** + * Combines installed jobs + moduleSiemJobs that don't overlap and sorts by name asc + * + * @param installedJobs + * @param moduleSiemJobs + */ +export const composeModuleAndInstalledJobs = ( + installedJobs: SiemJob[], + moduleSiemJobs: SiemJob[] +): SiemJob[] => { + const installedJobsIds = installedJobs.map(installedJob => installedJob.id); + + return [...installedJobs, ...moduleSiemJobs.filter(mj => !installedJobsIds.includes(mj.id))].sort( + (a, b) => a.id.localeCompare(b.id) + ); +}; +/** + * Creates a list of SiemJobs by composing JobSummary jobs (installed jobs) and Module + * jobs (pre-packaged SIEM jobs) into a single job object that can be used throughout the SIEM app + * + * @param jobSummaryData + * @param modulesData + * @param compatibleModules + */ +export const createSiemJobs = ( + jobSummaryData: JobSummary[], + modulesData: Module[], + compatibleModules: RecognizerModule[] +): SiemJob[] => { + // Create lookup of compatible modules + const compatibleModuleIds = compatibleModules.map(module => module.id); + + // Process modulesData: Filter to SIEM specific modules, and unpack jobs from modules + const moduleSiemJobs = getModuleJobs(modulesData, compatibleModuleIds); + + // Process jobSummaryData: Filter to SIEM jobs, and augment with moduleId/defaultIndexPattern/isCompatible + const installedJobs = getInstalledJobs(jobSummaryData, moduleSiemJobs, compatibleModuleIds); + + // Combine installed jobs + moduleSiemJobs that don't overlap, and sort by name asc + return composeModuleAndInstalledJobs(installedJobs, moduleSiemJobs); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/__snapshots__/filter_group.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/__snapshots__/filter_group.test.tsx.snap deleted file mode 100644 index aa8524366d2b2..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/__snapshots__/filter_group.test.tsx.snap +++ /dev/null @@ -1,52 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FilterGroup renders correctly against snapshot 1`] = ` - - - - - - - - Elastic jobs - - - Custom jobs - - - - -`; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/__snapshots__/job_switch.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/__snapshots__/job_switch.test.tsx.snap index aeb4ce089883b..7f7c63504f317 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/__snapshots__/job_switch.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/__snapshots__/job_switch.test.tsx.snap @@ -8,7 +8,7 @@ exports[`JobSwitch renders correctly against snapshot 1`] = ` grow={false} > { - test('renders correctly against snapshot', () => { - const wrapper = shallow( - - ); - expect(toJson(wrapper)).toMatchSnapshot(); - }); - - test('when you click filter onChange is called and filter updated', () => { - const mockSetShowAllJobs = jest.fn(); - const wrapper = mount( - - ); - - wrapper - .find('[data-test-subj="show-elastic-jobs-filter-button"]') - .first() - .simulate('click'); - wrapper.update(); - - expect(mockSetShowAllJobs.mock.calls[0]).toEqual([true]); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filter_group.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filter_group.tsx deleted file mode 100644 index 6a70fee696297..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filter_group.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { - EuiFilterButton, - EuiFilterGroup, - EuiFlexGroup, - EuiFlexItem, - // @ts-ignore no-exported-member - EuiSearchBar, -} from '@elastic/eui'; - -import { EuiSearchBarQuery } from '../../open_timeline/types'; -import * as i18n from '../translations'; - -interface FilterGroupProps { - showCustomJobs: boolean; - setShowCustomJobs: (showCustomJobs: boolean) => void; - showElasticJobs: boolean; - setShowElasticJobs: (showCustomJobs: boolean) => void; - setFilterQuery: (filterQuery: string) => void; -} - -export const FilterGroup = React.memo( - ({ showCustomJobs, setShowCustomJobs, showElasticJobs, setShowElasticJobs, setFilterQuery }) => ( - - - setFilterQuery(query.queryText.trim())} - /> - - - - - { - setShowCustomJobs(!showCustomJobs); - setShowElasticJobs(false); - }} - data-test-subj="show-custom-jobs-filter-button" - withNext - > - {i18n.SHOW_ELASTIC_JOBS} - - { - setShowElasticJobs(!showElasticJobs); - setShowCustomJobs(false); - }} - data-test-subj="show-elastic-jobs-filter-button" - > - {i18n.SHOW_CUSTOM_JOBS} - - - - - ) -); - -FilterGroup.displayName = 'FilterGroup'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/__snapshots__/groups_filter_popover.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/__snapshots__/groups_filter_popover.test.tsx.snap new file mode 100644 index 0000000000000..4c9a27b76060c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/__snapshots__/groups_filter_popover.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GroupsFilterPopover renders correctly against snapshot 1`] = ` + + Groups + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="none" +> + + auditbeat (2) + + + process (3) + + + winlogbeat (1) + + +`; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/__snapshots__/jobs_table_filters.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/__snapshots__/jobs_table_filters.test.tsx.snap new file mode 100644 index 0000000000000..fac91f75978f0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/__snapshots__/jobs_table_filters.test.tsx.snap @@ -0,0 +1,138 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`JobsTableFilters renders correctly against snapshot 1`] = ` + + + + + + + + + + + + + Elastic jobs + + + Custom jobs + + + + +`; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/groups_filter_popover.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/groups_filter_popover.test.tsx new file mode 100644 index 0000000000000..cb8748ea5e997 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/groups_filter_popover.test.tsx @@ -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 { mount, shallow } from 'enzyme'; +import toJson from 'enzyme-to-json'; +import * as React from 'react'; +import { GroupsFilterPopover } from './groups_filter_popover'; +import { mockSiemJobs } from '../../__mocks__/api'; +import { SiemJob } from '../../types'; +import { cloneDeep } from 'lodash/fp'; + +describe('GroupsFilterPopover', () => { + let siemJobs: SiemJob[]; + + beforeEach(() => { + siemJobs = cloneDeep(mockSiemJobs); + }); + + test('renders correctly against snapshot', () => { + const wrapper = shallow( + + ); + expect(toJson(wrapper)).toMatchSnapshot(); + }); + + test('when a filter is clicked, it becomes checked ', () => { + const mockOnSelectedGroupsChanged = jest.fn(); + const wrapper = mount( + + ); + + wrapper + .find('[data-test-subj="groups-filter-popover-button"]') + .first() + .simulate('click'); + wrapper.update(); + + wrapper + .find('EuiFilterSelectItem') + .first() + .simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('EuiFilterSelectItem') + .first() + .prop('checked') + ).toEqual('on'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/groups_filter_popover.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/groups_filter_popover.tsx new file mode 100644 index 0000000000000..e39046ba013c7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/groups_filter_popover.tsx @@ -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. + */ + +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { + EuiFilterButton, + EuiFilterSelectItem, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + EuiSpacer, +} from '@elastic/eui'; +import * as i18n from './translations'; +import { SiemJob } from '../../types'; +import { toggleSelectedGroup } from './toggle_selected_group'; + +interface GroupsFilterPopoverProps { + siemJobs: SiemJob[]; + onSelectedGroupsChanged: Dispatch>; +} + +/** + * Popover for selecting which SiemJob groups to filter on. Component extracts unique groups and + * their counts from the provided SiemJobs. The 'siem' group is filtered out as all jobs will be + * siem jobs + * + * @param siemJobs jobs to fetch groups from to display for filtering + * @param onSelectedGroupsChanged change listener to be notified when group selection changes + */ +export const GroupsFilterPopover = React.memo( + ({ siemJobs, onSelectedGroupsChanged }) => { + const [isGroupPopoverOpen, setIsGroupPopoverOpen] = useState(false); + const [selectedGroups, setSelectedGroups] = useState([]); + + const groups = siemJobs + .map(j => j.groups) + .flat() + .filter(g => g !== 'siem'); + const uniqueGroups = Array.from(new Set(groups)); + + useEffect(() => { + onSelectedGroupsChanged(selectedGroups); + }, [selectedGroups.sort().join()]); + + return ( + setIsGroupPopoverOpen(!isGroupPopoverOpen)} + isSelected={isGroupPopoverOpen} + hasActiveFilters={selectedGroups.length > 0} + numActiveFilters={selectedGroups.length} + > + {i18n.GROUPS} + + } + isOpen={isGroupPopoverOpen} + closePopover={() => setIsGroupPopoverOpen(!isGroupPopoverOpen)} + panelPaddingSize="none" + > + {uniqueGroups.map((group, index) => ( + toggleSelectedGroup(group, selectedGroups, setSelectedGroups)} + > + {`${group} (${groups.filter(g => g === group).length})`} + + ))} + {uniqueGroups.length === 0 && ( + + + + +

{i18n.NO_GROUPS_AVAILABLE}

+
+
+ )} +
+ ); + } +); + +GroupsFilterPopover.displayName = 'GroupsFilterPopover'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/jobs_table_filters.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/jobs_table_filters.test.tsx new file mode 100644 index 0000000000000..5838c3105de6d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/jobs_table_filters.test.tsx @@ -0,0 +1,124 @@ +/* + * 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 toJson from 'enzyme-to-json'; +import * as React from 'react'; +import { JobsTableFilters } from './jobs_table_filters'; +import { SiemJob } from '../../types'; +import { cloneDeep } from 'lodash/fp'; +import { mockSiemJobs } from '../../__mocks__/api'; + +describe('JobsTableFilters', () => { + let siemJobs: SiemJob[]; + + beforeEach(() => { + siemJobs = cloneDeep(mockSiemJobs); + }); + + test('renders correctly against snapshot', () => { + const wrapper = shallow(); + expect(toJson(wrapper)).toMatchSnapshot(); + }); + + test('when you click Elastic Jobs filter, state is updated and it is selected', () => { + const onFilterChanged = jest.fn(); + const wrapper = mount( + + ); + + wrapper + .find('[data-test-subj="show-elastic-jobs-filter-button"]') + .first() + .simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="show-elastic-jobs-filter-button"]') + .first() + .prop('hasActiveFilters') + ).toEqual(true); + }); + + test('when you click Custom Jobs filter, state is updated and it is selected', () => { + const onFilterChanged = jest.fn(); + const wrapper = mount( + + ); + + wrapper + .find('[data-test-subj="show-custom-jobs-filter-button"]') + .first() + .simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="show-custom-jobs-filter-button"]') + .first() + .prop('hasActiveFilters') + ).toEqual(true); + }); + + test('when you click Custom Jobs filter once, then Elastic Jobs filter, state is updated and selected changed', () => { + const onFilterChanged = jest.fn(); + const wrapper = mount( + + ); + + wrapper + .find('[data-test-subj="show-custom-jobs-filter-button"]') + .first() + .simulate('click'); + wrapper.update(); + + wrapper + .find('[data-test-subj="show-elastic-jobs-filter-button"]') + .first() + .simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="show-custom-jobs-filter-button"]') + .first() + .prop('hasActiveFilters') + ).toEqual(false); + expect( + wrapper + .find('[data-test-subj="show-elastic-jobs-filter-button"]') + .first() + .prop('hasActiveFilters') + ).toEqual(true); + }); + + test('when you click Custom Jobs filter twice, state is updated and it is revert', () => { + const onFilterChanged = jest.fn(); + const wrapper = mount( + + ); + + wrapper + .find('[data-test-subj="show-custom-jobs-filter-button"]') + .first() + .simulate('click'); + wrapper.update(); + + wrapper + .find('[data-test-subj="show-custom-jobs-filter-button"]') + .first() + .simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="show-custom-jobs-filter-button"]') + .first() + .prop('hasActiveFilters') + ).toEqual(false); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/jobs_table_filters.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/jobs_table_filters.tsx new file mode 100644 index 0000000000000..ba080757d34a8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/jobs_table_filters.tsx @@ -0,0 +1,95 @@ +/* + * 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, { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +import { + EuiFilterButton, + EuiFilterGroup, + EuiFlexGroup, + EuiFlexItem, + // @ts-ignore no-exported-member + EuiSearchBar, +} from '@elastic/eui'; +import { EuiSearchBarQuery } from '../../../open_timeline/types'; +import * as i18n from './translations'; +import { JobsFilters, SiemJob } from '../../types'; +import { GroupsFilterPopover } from './groups_filter_popover'; + +interface JobsTableFiltersProps { + siemJobs: SiemJob[]; + onFilterChanged: Dispatch>; +} + +/** + * Collection of filters for filtering data within the JobsTable. Contains search bar, Elastic/Custom + * Jobs filter button toggle, and groups selection + * + * @param siemJobs jobs to fetch groups from to display for filtering + * @param onFilterChanged change listener to be notified on filter changes + */ +export const JobsTableFilters = React.memo( + ({ siemJobs, onFilterChanged }) => { + const [filterQuery, setFilterQuery] = useState(''); + const [selectedGroups, setSelectedGroups] = useState([]); + const [showCustomJobs, setShowCustomJobs] = useState(false); + const [showElasticJobs, setShowElasticJobs] = useState(false); + + // Propagate filter changes to parent + useEffect(() => { + onFilterChanged({ filterQuery, showCustomJobs, showElasticJobs, selectedGroups }); + }, [filterQuery, selectedGroups.sort().join(), showCustomJobs, showElasticJobs]); + + return ( + + + setFilterQuery(query.queryText.trim())} + /> + + + + + + + + + + + { + setShowElasticJobs(!showElasticJobs); + setShowCustomJobs(false); + }} + data-test-subj="show-elastic-jobs-filter-button" + withNext + > + {i18n.SHOW_ELASTIC_JOBS} + + { + setShowCustomJobs(!showCustomJobs); + setShowElasticJobs(false); + }} + data-test-subj="show-custom-jobs-filter-button" + > + {i18n.SHOW_CUSTOM_JOBS} + + + + + ); + } +); + +JobsTableFilters.displayName = 'JobsTableFilters'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/toggle_selected_group.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/toggle_selected_group.test.tsx new file mode 100644 index 0000000000000..353012732e1ae --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/toggle_selected_group.test.tsx @@ -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 { toggleSelectedGroup } from './toggle_selected_group'; + +describe('#toggleSelectedGroup', () => { + let setSelectedGroups: jest.Mock; + beforeEach(() => { + setSelectedGroups = jest.fn(); + }); + + test('removes only job', () => { + toggleSelectedGroup('deadbeat', ['deadbeat'], setSelectedGroups); + expect(setSelectedGroups.mock.calls[0][0]).toEqual([]); + }); + + test('removes first job', () => { + toggleSelectedGroup('siem', ['siem', 'frankbeat', 'auditbeat'], setSelectedGroups); + expect(setSelectedGroups.mock.calls[0][0]).toEqual(['frankbeat', 'auditbeat']); + }); + + test('removes middle job', () => { + toggleSelectedGroup('frankbeat', ['siem', 'frankbeat', 'auditbeat'], setSelectedGroups); + expect(setSelectedGroups.mock.calls[0][0]).toEqual(['siem', 'auditbeat']); + }); + + test('removes last job', () => { + toggleSelectedGroup('auditbeat', ['siem', 'frankbeat', 'auditbeat'], setSelectedGroups); + expect(setSelectedGroups.mock.calls[0][0]).toEqual(['siem', 'frankbeat']); + }); + + test('adds job if element does not exist', () => { + toggleSelectedGroup('deadbeat', ['siem', 'frankbeat', 'auditbeat'], setSelectedGroups); + expect(setSelectedGroups.mock.calls[0][0]).toEqual([ + 'siem', + 'frankbeat', + 'auditbeat', + 'deadbeat', + ]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/toggle_selected_group.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/toggle_selected_group.tsx new file mode 100644 index 0000000000000..f6f252a4d7b9f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/toggle_selected_group.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch, SetStateAction } from 'react'; + +export const toggleSelectedGroup = ( + group: string, + selectedGroups: string[], + setSelectedGroups: Dispatch> +): void => { + const selectedGroupIndex = selectedGroups.indexOf(group); + const updatedSelectedGroups = [...selectedGroups]; + if (selectedGroupIndex >= 0) { + updatedSelectedGroups.splice(selectedGroupIndex, 1); + } else { + updatedSelectedGroups.push(group); + } + setSelectedGroups(updatedSelectedGroups); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/translations.ts b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/translations.ts new file mode 100644 index 0000000000000..b52a933407c2e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/filters/translations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const FILTER_PLACEHOLDER = i18n.translate( + 'xpack.siem.components.mlPopover.jobsTable.filters.searchFilterPlaceholder', + { + defaultMessage: 'e.g. rare_process_linux', + } +); + +export const GROUPS = i18n.translate( + 'xpack.siem.components.mlPopover.jobsTable.filters.groupsLabel', + { + defaultMessage: 'Groups', + } +); + +export const NO_GROUPS_AVAILABLE = i18n.translate( + 'xpack.siem.components.mlPopover.jobsTable.filters.noGroupsAvailableDescription', + { + defaultMessage: 'No Groups available', + } +); + +export const SHOW_ELASTIC_JOBS = i18n.translate( + 'xpack.siem.components.mlPopover.jobsTable.filters.showAllJobsLabel', + { + defaultMessage: 'Elastic jobs', + } +); + +export const SHOW_CUSTOM_JOBS = i18n.translate( + 'xpack.siem.components.mlPopover.jobsTable.filters.showSiemJobsLabel', + { + defaultMessage: 'Custom jobs', + } +); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx index 844d346405e0e..2e869cce9ddf7 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx @@ -9,17 +9,25 @@ import toJson from 'enzyme-to-json'; import * as React from 'react'; import { isChecked, isFailure, isJobLoading, JobSwitch } from './job_switch'; -import { mockOpenedJob } from '../__mocks__/api'; +import { cloneDeep } from 'lodash/fp'; +import { mockSiemJobs } from '../__mocks__/api'; +import { SiemJob } from '../types'; describe('JobSwitch', () => { + let siemJobs: SiemJob[]; let onJobStateChangeMock = jest.fn(); beforeEach(() => { + siemJobs = cloneDeep(mockSiemJobs); onJobStateChangeMock = jest.fn(); }); test('renders correctly against snapshot', () => { const wrapper = shallow( - + ); expect(toJson(wrapper)).toMatchSnapshot(); }); @@ -27,8 +35,8 @@ describe('JobSwitch', () => { test('should call onJobStateChange when the switch is clicked to be true/open', () => { const wrapper = mount( ); @@ -40,18 +48,18 @@ describe('JobSwitch', () => { target: { checked: true }, }); - expect(onJobStateChangeMock.mock.calls[0]).toEqual([ - 'siem-api-rare_process_linux_ecs', - 1562870521264, - true, - ]); + expect(onJobStateChangeMock.mock.calls[0][0].id).toEqual( + 'linux_anomalous_network_activity_ecs' + ); + expect(onJobStateChangeMock.mock.calls[0][1]).toEqual(1571022859393); + expect(onJobStateChangeMock.mock.calls[0][2]).toEqual(true); }); test('should have a switch when it is not in the loading state', () => { const wrapper = mount( ); @@ -61,8 +69,8 @@ describe('JobSwitch', () => { test('should not have a switch when it is in the loading state', () => { const wrapper = mount( ); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx index 1b06a7bcadf0c..b62478acaf197 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx @@ -7,7 +7,7 @@ import styled from 'styled-components'; import React, { useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSwitch } from '@elastic/eui'; -import { Job } from '../types'; +import { SiemJob } from '../types'; const StaticSwitch = styled(EuiSwitch)` .euiSwitch__thumb, @@ -19,9 +19,9 @@ const StaticSwitch = styled(EuiSwitch)` StaticSwitch.displayName = 'StaticSwitch'; export interface JobSwitchProps { - job: Job; - isSummaryLoading: boolean; - onJobStateChange: (jobName: string, latestTimestampMs: number, enable: boolean) => void; + job: SiemJob; + isSiemJobsLoading: boolean; + onJobStateChange: (job: SiemJob, latestTimestampMs: number, enable: boolean) => void; } // Based on ML Job/Datafeed States from x-pack/legacy/plugins/ml/common/constants/states.js @@ -42,13 +42,13 @@ export const isFailure = (jobState: string, datafeedState: string): boolean => { }; export const JobSwitch = React.memo( - ({ job, isSummaryLoading, onJobStateChange }) => { + ({ job, isSiemJobsLoading, onJobStateChange }) => { const [isLoading, setIsLoading] = useState(false); return ( - {isSummaryLoading || isLoading || isJobLoading(job.jobState, job.datafeedId) ? ( + {isSiemJobsLoading || isLoading || isJobLoading(job.jobState, job.datafeedId) ? ( ) : ( ( checked={isChecked(job.jobState, job.datafeedState)} onChange={e => { setIsLoading(true); - onJobStateChange(job.id, job.latestTimestampMs || 0, e.target.checked); + onJobStateChange(job, job.latestTimestampMs || 0, e.target.checked); }} /> )} diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/jobs_table.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/jobs_table.test.tsx index 932dad267e3fa..5bd1660d2db48 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/jobs_table.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/jobs_table.test.tsx @@ -8,51 +8,43 @@ import { shallow, mount } from 'enzyme'; import toJson from 'enzyme-to-json'; import * as React from 'react'; import { JobsTable } from './jobs_table'; -import { mockJobsSummaryResponse } from '../__mocks__/api'; +import { mockSiemJobs } from '../__mocks__/api'; import { cloneDeep } from 'lodash/fp'; +import { SiemJob } from '../types'; describe('JobsTable', () => { + let siemJobs: SiemJob[]; let onJobStateChangeMock = jest.fn(); beforeEach(() => { + siemJobs = cloneDeep(mockSiemJobs); onJobStateChangeMock = jest.fn(); }); test('renders correctly against snapshot', () => { const wrapper = shallow( - + ); expect(toJson(wrapper)).toMatchSnapshot(); }); test('should render the hyperlink which points specifically to the job id', () => { const wrapper = mount( - + ); expect( wrapper .find('[data-test-subj="jobs-table-link"]') .first() .props().href - ).toEqual('/test/base/path/app/ml#/jobs?mlManagement=(jobId:rc-rare-process-windows-5)'); + ).toEqual( + '/test/base/path/app/ml#/jobs?mlManagement=(jobId:linux_anomalous_network_activity_ecs)' + ); }); test('should render the hyperlink with URI encodings which points specifically to the job id', () => { - const cloneJobsSummaryResponse = cloneDeep(mockJobsSummaryResponse); - cloneJobsSummaryResponse[0].id = 'job id with spaces'; + siemJobs[0].id = 'job id with spaces'; const wrapper = mount( - + ); expect( wrapper @@ -64,11 +56,7 @@ describe('JobsTable', () => { test('should call onJobStateChange when the switch is clicked to be true/open', () => { const wrapper = mount( - + ); wrapper .find('[data-test-subj="job-switch"] input') @@ -76,31 +64,19 @@ describe('JobsTable', () => { .simulate('change', { target: { checked: true }, }); - expect(onJobStateChangeMock.mock.calls[0]).toEqual([ - 'rc-rare-process-windows-5', - 1561402325194, - true, - ]); + expect(onJobStateChangeMock.mock.calls[0]).toEqual([siemJobs[0], 1571022859393, true]); }); test('should have a switch when it is not in the loading state', () => { const wrapper = mount( - + ); expect(wrapper.find('[data-test-subj="job-switch"]').exists()).toBe(true); }); test('should not have a switch when it is in the loading state', () => { const wrapper = mount( - + ); expect(wrapper.find('[data-test-subj="job-switch"]').exists()).toBe(false); }); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/jobs_table.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/jobs_table.tsx index 1cd429f92b6a9..b15c684b1bbbe 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/jobs_table.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/jobs_table.tsx @@ -9,17 +9,21 @@ import React, { useEffect, useState } from 'react'; import { CENTER_ALIGNMENT, + EuiBadge, EuiBasicTable, EuiButton, EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, EuiLink, EuiText, } from '@elastic/eui'; import styled from 'styled-components'; -import * as i18n from '../translations'; +import * as i18n from './translations'; import { JobSwitch } from './job_switch'; -import { Job } from '../types'; +import { SiemJob } from '../types'; const JobNameWrapper = styled.div` margin: 5px 0; @@ -32,11 +36,11 @@ const truncateThreshold = 200; const getJobsTableColumns = ( isLoading: boolean, - onJobStateChange: (jobName: string, latestTimestampMs: number, enable: boolean) => void + onJobStateChange: (job: SiemJob, latestTimestampMs: number, enable: boolean) => void ) => [ { name: i18n.COLUMN_JOB_NAME, - render: ({ id, description }: Job) => ( + render: ({ id, description }: SiemJob) => ( ), }, + { + name: i18n.COLUMN_GROUPS, + render: ({ groups }: SiemJob) => ( + + {groups.map(group => ( + + {group} + + ))} + + ), + width: '140px', + }, { name: i18n.COLUMN_RUN_JOB, - render: (job: Job) => ( - - ), + render: (job: SiemJob) => + job.isCompatible ? ( + + ) : ( + + ), align: CENTER_ALIGNMENT, width: '80px', }, ]; -const getPaginatedItems = (items: Job[], pageIndex: number, pageSize: number): Job[] => +const getPaginatedItems = (items: SiemJob[], pageIndex: number, pageSize: number): SiemJob[] => items.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize); export interface JobTableProps { isLoading: boolean; - jobs: Job[]; - onJobStateChange: (jobName: string, latestTimestampMs: number, enable: boolean) => void; + jobs: SiemJob[]; + onJobStateChange: (job: SiemJob, latestTimestampMs: number, enable: boolean) => void; } export const JobsTable = React.memo(({ isLoading, jobs, onJobStateChange }: JobTableProps) => { diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/translations.ts b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/translations.ts new file mode 100644 index 0000000000000..a508fda17228e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/translations.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const COLUMN_JOB_NAME = i18n.translate( + 'xpack.siem.components.mlPopup.jobsTable.jobNameColumn', + { + defaultMessage: 'Job name', + } +); + +export const COLUMN_GROUPS = i18n.translate('xpack.siem.components.mlPopup.jobsTable.tagsColumn', { + defaultMessage: 'Groups', +}); + +export const COLUMN_RUN_JOB = i18n.translate( + 'xpack.siem.components.mlPopup.jobsTable.runJobColumn', + { + defaultMessage: 'Run job', + } +); + +export const NO_ITEMS_TEXT = i18n.translate( + 'xpack.siem.components.mlPopup.jobsTable.noItemsDescription', + { + defaultMessage: 'No SIEM Machine Learning jobs found', + } +); + +export const CREATE_CUSTOM_JOB = i18n.translate( + 'xpack.siem.components.mlPopup.jobsTable.createCustomJobButtonLabel', + { + defaultMessage: 'Create custom job', + } +); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/ml_modules.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/ml_modules.tsx new file mode 100644 index 0000000000000..99d805b8e9234 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/ml_modules.tsx @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * ML Modules relevant to the SIEM App that should be used to display jobs within the Anomaly + * Detection UI. Added as part of: https://github.com/elastic/kibana/pull/39678/files + * + */ +export const mlModules: string[] = ['siem_auditbeat', 'siem_winlogbeat']; diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/ml_popover.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/ml_popover.tsx index 6815c23ebaa46..2c3bc469a51df 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/ml_popover.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/ml_popover.tsx @@ -4,53 +4,42 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiPopover, EuiPopoverTitle, EuiSpacer } from '@elastic/eui'; -import React, { useContext, useEffect, useReducer, useState } from 'react'; +import { EuiButton, EuiCallOut, EuiPopover, EuiPopoverTitle, EuiSpacer } from '@elastic/eui'; +import React, { useContext, useReducer, useState } from 'react'; import styled from 'styled-components'; import moment from 'moment'; - -import { useJobSummaryData } from './hooks/use_job_summary_data'; import * as i18n from './translations'; -import { Job } from './types'; +import { JobsFilters, JobSummary, SiemJob } from './types'; import { hasMlAdminPermissions } from '../ml/permissions/has_ml_admin_permissions'; import { MlCapabilitiesContext } from '../ml/permissions/ml_capabilities_provider'; import { JobsTable } from './jobs_table/jobs_table'; import { setupMlJob, startDatafeeds, stopDatafeeds } from './api'; -import { useIndexPatterns } from './hooks/use_index_patterns'; import { UpgradeContents } from './upgrade_contents'; -import { FilterGroup } from './jobs_table/filter_group'; +import { JobsTableFilters } from './jobs_table/filters/jobs_table_filters'; import { ShowingCount } from './jobs_table/showing_count'; import { PopoverDescription } from './popover_description'; -import { - getConfigTemplatesToInstall, - getIndexPatternTitles, - getJobsToDisplay, - getJobsToInstall, - getStablePatternTitles, -} from './helpers'; -import { configTemplates } from './config_templates'; import { useStateToaster } from '../toasters'; import { errorToToaster } from '../ml/api/error_to_toaster'; -import { useKibanaUiSetting } from '../../lib/settings/use_kibana_ui_setting'; -import { DEFAULT_KBN_VERSION } from '../../../common/constants'; import { METRIC_TYPE, TELEMETRY_EVENT, trackUiAction as track } from '../../lib/track_usage'; +import { useSiemJobs } from './hooks/use_siem_jobs'; +import { filterJobs } from './helpers'; const PopoverContentsDiv = styled.div` - max-width: 550px; + max-width: 684px; `; PopoverContentsDiv.displayName = 'PopoverContentsDiv'; interface State { isLoading: boolean; - jobs: Job[]; + jobs: JobSummary[]; refreshToggle: boolean; } type Action = | { type: 'refresh' } | { type: 'loading' } - | { type: 'success'; results: Job[] } + | { type: 'success'; results: JobSummary[] } | { type: 'failure' }; function mlPopoverReducer(state: State, action: Action): State { @@ -92,26 +81,40 @@ const initialState: State = { refreshToggle: true, }; +const defaultFilterProps: JobsFilters = { + filterQuery: '', + showCustomJobs: false, + showElasticJobs: false, + selectedGroups: [], +}; + export const MlPopover = React.memo(() => { const [{ refreshToggle }, dispatch] = useReducer(mlPopoverReducer, initialState); const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [showCustomJobs, setShowCustomJobs] = useState(false); - const [showElasticJobs, setShowElasticJobs] = useState(false); - const [isLoadingJobSummaryData, jobSummaryData] = useJobSummaryData([], refreshToggle); - const [isCreatingJobs, setIsCreatingJobs] = useState(false); - const [filterQuery, setFilterQuery] = useState(''); + const [filterProperties, setFilterProperties] = useState(defaultFilterProps); + const [isLoadingSiemJobs, siemJobs] = useSiemJobs(refreshToggle); const [, dispatchToaster] = useStateToaster(); - const [, configuredIndexPatterns] = useIndexPatterns(refreshToggle); const capabilities = useContext(MlCapabilitiesContext); - const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); - const headers = { 'kbn-version': kbnVersion }; - - const configuredIndexPatternTitles = getIndexPatternTitles(configuredIndexPatterns); // Enable/Disable Job & Datafeed -- passed to JobsTable for use as callback on JobSwitch - const enableDatafeed = async (jobName: string, latestTimestampMs: number, enable: boolean) => { - submitTelemetry(jobName, enable, embeddedJobIds); + const enableDatafeed = async (job: SiemJob, latestTimestampMs: number, enable: boolean) => { + submitTelemetry(job, enable); + + if (!job.isInstalled) { + try { + await setupMlJob({ + configTemplate: job.moduleId, + indexPatternName: job.defaultIndexPattern, + jobIdErrorFilter: [job.id], + groups: job.groups, + }); + } catch (error) { + errorToToaster({ title: i18n.CREATE_JOB_FAILURE, error, dispatchToaster }); + dispatch({ type: 'refresh' }); + return; + } + } // Max start time for job is no more than two weeks ago to ensure job performance const maxStartTime = moment @@ -122,14 +125,14 @@ export const MlPopover = React.memo(() => { if (enable) { const startTime = Math.max(latestTimestampMs, maxStartTime); try { - await startDatafeeds([`datafeed-${jobName}`], headers, startTime); + await startDatafeeds({ datafeedIds: [`datafeed-${job.id}`], start: startTime }); } catch (error) { track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_ENABLE_FAILURE); errorToToaster({ title: i18n.START_JOB_FAILURE, error, dispatchToaster }); } } else { try { - await stopDatafeeds([`datafeed-${jobName}`], headers); + await stopDatafeeds({ datafeedIds: [`datafeed-${job.id}`] }); } catch (error) { track(METRIC_TYPE.COUNT, TELEMETRY_EVENT.JOB_DISABLE_FAILURE); errorToToaster({ title: i18n.STOP_JOB_FAILURE, error, dispatchToaster }); @@ -138,60 +141,12 @@ export const MlPopover = React.memo(() => { dispatch({ type: 'refresh' }); }; - // All jobs from embedded configTemplates that should be installed - const embeddedJobIds = getJobsToInstall(configTemplates); + const filteredJobs = filterJobs({ + jobs: siemJobs, + ...filterProperties, + }); - // Jobs currently installed retrieved via ml jobs_summary api for 'siem' group - const siemGroupJobIds = jobSummaryData != null ? jobSummaryData.map(job => job.id) : []; - const installedJobIds = embeddedJobIds.filter(job => siemGroupJobIds.includes(job)); - - // Config templates that still need to be installed and have a defaultIndexPattern that is configured - const configTemplatesToInstall = getConfigTemplatesToInstall( - configTemplates, - installedJobIds, - configuredIndexPatternTitles || [] - ); - - // Filter installed job to show all 'siem' group jobs or just embedded - const jobsToDisplay = getJobsToDisplay( - jobSummaryData, - embeddedJobIds, - showCustomJobs, - showElasticJobs, - filterQuery - ); - - // Install Config Templates as effect of opening popover - useEffect(() => { - if ( - isPopoverOpen && - jobSummaryData != null && - configuredIndexPatternTitles.length > 0 && - configTemplatesToInstall.length > 0 - ) { - const setupJobs = async () => { - setIsCreatingJobs(true); - try { - await Promise.all( - configTemplatesToInstall.map(configTemplate => { - return setupMlJob({ - configTemplate: configTemplate.name, - indexPatternName: configTemplate.defaultIndexPattern, - groups: ['siem'], - headers, - }); - }) - ); - setIsCreatingJobs(false); - dispatch({ type: 'refresh' }); - } catch (error) { - errorToToaster({ title: i18n.CREATE_JOB_FAILURE, error, dispatchToaster }); - setIsCreatingJobs(false); - } - }; - setupJobs(); - } - }, [jobSummaryData, getStablePatternTitles(configuredIndexPatternTitles)]); + const incompatibleJobCount = siemJobs.filter(j => !j.isCompatible).length; if (!capabilities.isPlatinumOrTrialLicense) { // If the user does not have platinum show upgrade UI @@ -243,21 +198,30 @@ export const MlPopover = React.memo(() => { - + - + - + + + {incompatibleJobCount > 0 && ( + <> + +

{i18n.MODULE_NOT_COMPATIBLE_DESCRIPTION}

+
+ + + + )}
@@ -269,11 +233,11 @@ export const MlPopover = React.memo(() => { } }); -const submitTelemetry = (jobName: string, enabled: boolean, embeddedJobIds: string[]) => { +const submitTelemetry = (job: SiemJob, enabled: boolean) => { // Report type of job enabled/disabled track( METRIC_TYPE.COUNT, - embeddedJobIds.includes(jobName) + job.isElasticJob ? enabled ? TELEMETRY_EVENT.SIEM_JOB_ENABLED : TELEMETRY_EVENT.SIEM_JOB_DISABLED diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/popover_description.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/popover_description.test.tsx index 0c91773c48556..8f90877feb72f 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/popover_description.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/popover_description.test.tsx @@ -9,7 +9,7 @@ import toJson from 'enzyme-to-json'; import * as React from 'react'; import { PopoverDescription } from './popover_description'; -describe('FilterGroup', () => { +describe('JobsTableFilters', () => { test('renders correctly against snapshot', () => { const wrapper = shallow(); expect(toJson(wrapper)).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/translations.ts b/x-pack/legacy/plugins/siem/public/components/ml_popover/translations.ts index 0ea9960edc16a..3fc9f3a484b89 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/translations.ts @@ -32,46 +32,18 @@ export const LICENSE_BUTTON = i18n.translate('xpack.siem.components.mlPopup.lice defaultMessage: 'Manage license', }); -export const FILTER_PLACEHOLDER = i18n.translate( - 'xpack.siem.components.mlPopup.filterPlaceholder', +export const MODULE_NOT_COMPATIBLE_TITLE = (incompatibleJobCount: number) => + i18n.translate('xpack.siem.components.mlPopup.moduleNotCompatibleTitle', { + values: { incompatibleJobCount }, + defaultMessage: + '{incompatibleJobCount} {incompatibleJobCount, plural, =1 {job} other {jobs}} are currently unavailable', + }); + +export const MODULE_NOT_COMPATIBLE_DESCRIPTION = i18n.translate( + 'xpack.siem.components.mlPopup.moduleNotCompatibleDescription', { - defaultMessage: 'e.g. rare_process_linux', - } -); - -export const SHOW_ELASTIC_JOBS = i18n.translate('xpack.siem.components.mlPopup.showAllJobsLabel', { - defaultMessage: 'Elastic jobs', -}); - -export const SHOW_CUSTOM_JOBS = i18n.translate('xpack.siem.components.mlPopup.showSiemJobsLabel', { - defaultMessage: 'Custom jobs', -}); - -export const COLUMN_JOB_NAME = i18n.translate( - 'xpack.siem.components.mlPopup.jobsTable.jobNameColumn', - { - defaultMessage: 'Job name', - } -); - -export const COLUMN_RUN_JOB = i18n.translate( - 'xpack.siem.components.mlPopup.jobsTable.runJobColumn', - { - defaultMessage: 'Run job', - } -); - -export const NO_ITEMS_TEXT = i18n.translate( - 'xpack.siem.components.mlPopup.jobsTable.noItemsDescription', - { - defaultMessage: 'No SIEM Machine Learning jobs found', - } -); - -export const CREATE_CUSTOM_JOB = i18n.translate( - 'xpack.siem.components.mlPopup.jobsTable.createCustomJobButtonLabel', - { - defaultMessage: 'Create custom job', + defaultMessage: + 'You may be missing the required index patterns. Learn more in our documentation.', } ); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts b/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts index 5e8f6aa40b5a9..203f4f646b62b 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts @@ -12,26 +12,104 @@ export interface Group { calendarIds: string[]; } +export interface CheckRecognizerProps { + indexPatternName: string; + headers?: Record; + signal: AbortSignal; +} + +export interface RecognizerModule { + id: string; + title: string; + query: Record; + description: string; + logo: { + icon: string; + }; +} + +export interface GetModulesProps { + moduleId?: string; + headers?: Record; + signal: AbortSignal; +} + +export interface Module { + id: string; + title: string; + description: string; + type: string; + logoFile: string; + defaultIndexPattern: string; + query: Record; + jobs: ModuleJob[]; + datafeeds: ModuleDatafeed[]; + kibana: object; +} + +/** + * Representation of an ML Job as returned from `the ml/modules/get_module` API + */ +export interface ModuleJob { + id: string; + config: { + groups: string[]; + description: string; + analysis_config: { + bucket_span: string; + summary_count_field_name?: string; + detectors: Detector[]; + influencers: string[]; + }; + analysis_limits: { + model_memory_limit: string; + }; + data_description: { + time_field: string; + time_format?: string; + }; + model_plot_config?: { + enabled: boolean; + }; + custom_settings: { + created_by: string; + custom_urls: CustomURL[]; + }; + job_type: string; + }; +} + +// TODO: Speak to ML team about why the get_module API will sometimes return indexes and other times indices +// See mockGetModuleResponse for examples +export interface ModuleDatafeed { + id: string; + config: { + job_id: string; + indexes?: string[]; + indices?: string[]; + query: Record; + }; +} + export interface MlSetupArgs { configTemplate: string; indexPatternName: string; + jobIdErrorFilter: string[]; groups: string[]; prefix?: string; - headers: Record; + headers?: Record; } -export interface ConfigTemplate { - name: string; - defaultIndexPattern: string; - jobs: string[]; -} - -export interface Job { +/** + * Representation of an ML Job as returned from the `ml/jobs/jobs_summary` API + */ +export interface JobSummary { datafeedId: string; datafeedIndices: string[]; datafeedState: string; description: string; earliestTimestampMs?: number; + latestResultsTimestampMs?: number; groups: string[]; hasDatafeed: boolean; id: string; @@ -43,6 +121,37 @@ export interface Job { processed_record_count: number; } +export interface Detector { + detector_description: string; + function: string; + by_field_name: string; + partition_field_name?: string; +} + +export interface CustomURL { + url_name: string; + url_value: string; +} + +/** + * Representation of an ML Job as used by the SIEM App -- a composition of ModuleJob and JobSummary + * that includes necessary metadata like moduleName, defaultIndexPattern, etc. + */ +export interface SiemJob extends JobSummary { + moduleId: string; + defaultIndexPattern: string; + isCompatible: boolean; + isInstalled: boolean; + isElasticJob: boolean; +} + +export interface AugmentedSiemJobFields { + moduleId: string; + defaultIndexPattern: string; + isCompatible: boolean; + isElasticJob: boolean; +} + export interface SetupMlResponseJob { id: string; success: boolean; @@ -65,9 +174,16 @@ export interface SetupMlResponse { export interface StartDatafeedResponse { [key: string]: { started: boolean; + error?: string; }; } +export interface ErrorResponse { + statusCode?: number; + error?: string; + message?: string; +} + export interface StopDatafeedResponse { [key: string]: { stopped: boolean; @@ -90,9 +206,9 @@ export interface IndexPatternSavedObject { version: string; } -export interface IndexPatternResponse { - page: number; - per_page: number; - saved_objects: IndexPatternSavedObject[]; - total: number; +export interface JobsFilters { + filterQuery: string; + showCustomJobs: boolean; + showElasticJobs: boolean; + selectedGroups: string[]; } diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/upgrade_contents.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/upgrade_contents.test.tsx index c866b0fd190f4..13d48c0e62b6d 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/upgrade_contents.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/upgrade_contents.test.tsx @@ -9,7 +9,7 @@ import toJson from 'enzyme-to-json'; import * as React from 'react'; import { UpgradeContents } from './upgrade_contents'; -describe('FilterGroup', () => { +describe('JobsTableFilters', () => { test('renders correctly against snapshot', () => { const wrapper = shallow(); expect(toJson(wrapper)).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 7c9dfb87400a2..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,162 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Domains Table Component Rendering it renders the default Domains table 1`] = ` - -`; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx deleted file mode 100644 index cf5da3fbebba6..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx +++ /dev/null @@ -1,200 +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 { EuiIcon, EuiToolTip } from '@elastic/eui'; -import numeral from '@elastic/numeral'; -import { getOr, isEmpty } from 'lodash/fp'; -import moment from 'moment'; -import React from 'react'; -import { StaticIndexPattern } from 'ui/index_patterns'; - -import { - DomainsEdges, - DomainsItem, - DomainsNetworkField, - FlowDirection, - FlowTarget, -} from '../../../../graphql/types'; -import { assertUnreachable } from '../../../../lib/helpers'; -import { escapeQueryValue } from '../../../../lib/keury'; -import { networkModel } from '../../../../store'; -import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_wrapper'; -import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; -import { defaultToEmptyTag, getEmptyTagValue } from '../../../empty_value'; -import { PreferenceFormattedDate } from '../../../formatted_date'; -import { Columns } from '../../../paginated_table'; -import { LocalizedDateTooltip } from '../../../localized_date_tooltip'; -import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; -import { PreferenceFormattedBytes } from '../../../formatted_bytes'; -import { Provider } from '../../../timeline/data_providers/provider'; -import { AddToKql } from '../../add_to_kql'; - -import * as i18n from './translations'; - -export type DomainsColumns = [ - Columns, - Columns, - Columns, - Columns, - Columns, - Columns -]; - -export const getDomainsColumns = ( - indexPattern: StaticIndexPattern, - ip: string, - flowDirection: FlowDirection, - flowTarget: FlowTarget, - type: networkModel.NetworkType, - tableId: string -): DomainsColumns => [ - { - field: `node.${flowTarget}.domainName`, - name: i18n.DOMAIN_NAME, - truncateText: false, - hideForMobile: false, - sortable: true, - render: domainName => { - const domainNameAttr = `${flowTarget}.domain`; - if (domainName != null) { - const id = escapeDataProviderId( - `${tableId}-table-${flowTarget}-${flowDirection}-domain-${domainName}` - ); - return ( - - snapshot.isDragging ? ( - - - - ) : ( - <>{domainName} - ) - } - /> - ); - } else { - return getEmptyTagValue(); - } - }, - }, - { - field: 'node.network.direction', - name: i18n.DIRECTION, - truncateText: false, - hideForMobile: false, - render: directions => - isEmpty(directions) - ? getEmptyTagValue() - : directions && - directions.map((direction, index) => ( - - <> - {defaultToEmptyTag(direction)} - {index < directions.length - 1 ? '\u00A0' : null} - - - )), - }, - { - field: 'node.network.bytes', - name: i18n.BYTES, - truncateText: false, - hideForMobile: false, - sortable: true, - render: bytes => { - if (bytes != null) { - return ; - } else { - return getEmptyTagValue(); - } - }, - }, - { - field: 'node.network.packets', - name: i18n.PACKETS, - truncateText: false, - hideForMobile: false, - sortable: true, - render: packets => { - if (packets != null) { - return numeral(packets).format('0,000'); - } else { - return getEmptyTagValue(); - } - }, - }, - { - field: `node.${flowTarget}.uniqueIpCount`, - name: getFlowTargetTitle(flowTarget), - truncateText: false, - hideForMobile: false, - sortable: true, - render: uniqueIpCount => { - if (uniqueIpCount != null) { - return numeral(uniqueIpCount).format('0,000'); - } else { - return getEmptyTagValue(); - } - }, - }, - { - name: ( - - <> - {i18n.LAST_SEEN}{' '} - - - - ), - truncateText: false, - hideForMobile: false, - render: ({ node }) => { - const lastSeenAttr = `${flowTarget}.lastSeen`; - const lastSeen = getOr(null, lastSeenAttr, node); - if (lastSeen != null) { - return ( - - - - ); - } - return getEmptyTagValue(); - }, - }, -]; - -const getFlowTargetTitle = (flowTarget: FlowTarget): string => { - switch (flowTarget) { - case FlowTarget.client: - return i18n.UNIQUE_CLIENTS; - case FlowTarget.server: - return i18n.UNIQUE_SERVERS; - case FlowTarget.source: - return i18n.UNIQUE_DESTINATIONS; - case FlowTarget.destination: - return i18n.UNIQUE_SOURCES; - } - assertUnreachable(flowTarget); -}; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/index.test.tsx deleted file mode 100644 index 45b5998f4d45c..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/index.test.tsx +++ /dev/null @@ -1,130 +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 toJson from 'enzyme-to-json'; -import { getOr } from 'lodash/fp'; -import * as React from 'react'; -import { MockedProvider } from 'react-apollo/test-utils'; -import { Provider as ReduxStoreProvider } from 'react-redux'; - -import { FlowTarget } from '../../../../graphql/types'; -import { - apolloClientObservable, - mockIndexPattern, - mockGlobalState, - TestProviders, -} from '../../../../mock'; -import { createStore, networkModel, State } from '../../../../store'; - -import { DomainsTable } from '.'; -import { mockDomainsData } from './mock'; - -jest.mock('../../../../lib/settings/use_kibana_ui_setting'); - -describe('Domains Table Component', () => { - const loadPage = jest.fn(); - const ip = '10.10.10.10'; - const state: State = mockGlobalState; - - let store = createStore(state, apolloClientObservable); - - beforeEach(() => { - store = createStore(state, apolloClientObservable); - }); - - describe('Rendering', () => { - test('it renders the default Domains table', () => { - const wrapper = shallow( - - - - ); - - expect(toJson(wrapper)).toMatchSnapshot(); - }); - }); - - describe('Sorting on Table', () => { - test('when you click on the column header, you should show the sorting icon', () => { - const wrapper = mount( - - - - - - ); - expect(store.getState().network.details.queries!.domains.domainsSortField).toEqual({ - direction: 'desc', - field: 'bytes', - }); - - wrapper - .find('.euiTable thead tr th button') - .at(1) - .simulate('click'); - - wrapper.update(); - - expect(store.getState().network.details.queries!.domains.domainsSortField).toEqual({ - direction: 'asc', - field: 'bytes', - }); - expect( - wrapper - .find('.euiTable thead tr th button') - .first() - .text() - ).toEqual('DomainClick to sort in ascending order'); - expect( - wrapper - .find('.euiTable thead tr th button') - .at(1) - .text() - ).toEqual('BytesClick to sort in descending order'); - expect( - wrapper - .find('.euiTable thead tr th button') - .at(1) - .find('svg') - ).toBeTruthy(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/index.tsx deleted file mode 100644 index 52cd8ec70f326..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/index.tsx +++ /dev/null @@ -1,235 +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 { isEqual } from 'lodash/fp'; -import React from 'react'; -import { connect } from 'react-redux'; -import { ActionCreator } from 'redux'; -import { StaticIndexPattern } from 'ui/index_patterns'; - -import { networkActions } from '../../../../store/actions'; -import { - Direction, - DomainsEdges, - DomainsFields, - DomainsSortField, - FlowDirection, - FlowTarget, -} from '../../../../graphql/types'; -import { networkModel, networkSelectors, State } from '../../../../store'; -import { FlowDirectionSelect } from '../../../flow_controls/flow_direction_select'; -import { Criteria, ItemsPerRow, PaginatedTable, SortingBasicTable } from '../../../paginated_table'; - -import { getDomainsColumns } from './columns'; -import * as i18n from './translations'; -const tableType = networkModel.IpDetailsTableType.domains; - -interface OwnProps { - data: DomainsEdges[]; - flowTarget: FlowTarget; - fakeTotalCount: number; - id: string; - isInspect: boolean; - indexPattern: StaticIndexPattern; - ip: string; - loading: boolean; - loadPage: (newActivePage: number) => void; - showMorePagesIndicator: boolean; - totalCount: number; - type: networkModel.NetworkType; -} - -interface DomainsTableReduxProps { - activePage: number; - domainsSortField: DomainsSortField; - flowDirection: FlowDirection; - limit: number; -} - -interface DomainsTableDispatchProps { - updateDomainsDirection: ActionCreator<{ - flowDirection: FlowDirection; - networkType: networkModel.NetworkType; - }>; - updateDomainsLimit: ActionCreator<{ - limit: number; - networkType: networkModel.NetworkType; - }>; - updateDomainsSort: ActionCreator<{ - domainsSort: DomainsSortField; - networkType: networkModel.NetworkType; - }>; - updateTableActivePage: ActionCreator<{ - activePage: number; - tableType: networkModel.IpDetailsTableType; - }>; -} - -type DomainsTableProps = OwnProps & DomainsTableReduxProps & DomainsTableDispatchProps; - -const rowItems: ItemsPerRow[] = [ - { - text: i18n.ROWS_5, - numberOfRow: 5, - }, - { - text: i18n.ROWS_10, - numberOfRow: 10, - }, -]; - -export const DomainsTableId = 'domains-table'; - -class DomainsTableComponent extends React.PureComponent { - public render() { - const { - activePage, - data, - domainsSortField, - fakeTotalCount, - flowDirection, - flowTarget, - id, - indexPattern, - ip, - isInspect, - limit, - loading, - loadPage, - showMorePagesIndicator, - totalCount, - type, - updateDomainsLimit, - updateTableActivePage, - } = this.props; - - return ( - - } - headerTitle={i18n.DOMAINS} - headerUnit={i18n.UNIT(totalCount)} - id={id} - isInspect={isInspect} - itemsPerRow={rowItems} - limit={limit} - loading={loading} - loadPage={newActivePage => loadPage(newActivePage)} - onChange={this.onChange} - pageOfItems={data} - sorting={getSortField(domainsSortField, flowTarget)} - totalCount={fakeTotalCount} - updateActivePage={newPage => - updateTableActivePage({ - activePage: newPage, - tableType, - }) - } - updateLimitPagination={newLimit => - updateDomainsLimit({ limit: newLimit, networkType: type }) - } - /> - ); - } - - private onChange = (criteria: Criteria) => { - if (criteria.sort != null) { - const splitField = criteria.sort.field.split('.'); - const newDomainsSort: DomainsSortField = { - field: getSortFromString(splitField[splitField.length - 1]), - direction: criteria.sort.direction, - }; - if (!isEqual(newDomainsSort, this.props.domainsSortField)) { - this.props.updateDomainsSort({ - domainsSortField: newDomainsSort, - networkType: this.props.type, - }); - } - } - }; - - private onChangeDomainsDirection = (flowDirection: FlowDirection) => - this.props.updateDomainsDirection({ flowDirection, networkType: this.props.type }); -} - -const makeMapStateToProps = () => { - const getDomainsSelector = networkSelectors.domainsSelector(); - const mapStateToProps = (state: State) => ({ - ...getDomainsSelector(state), - }); - return mapStateToProps; -}; - -export const DomainsTable = connect( - makeMapStateToProps, - { - updateDomainsLimit: networkActions.updateDomainsLimit, - updateDomainsDirection: networkActions.updateDomainsFlowDirection, - updateDomainsSort: networkActions.updateDomainsSort, - updateTableActivePage: networkActions.updateIpDetailsTableActivePage, - } -)(DomainsTableComponent); - -const getSortField = (sortField: DomainsSortField, flowTarget: FlowTarget): SortingBasicTable => { - switch (sortField.field) { - case DomainsFields.domainName: - return { - field: `node.${flowTarget}.${sortField.field}`, - direction: sortField.direction, - }; - case DomainsFields.bytes: - return { - field: `node.network.${sortField.field}`, - direction: sortField.direction, - }; - case DomainsFields.packets: - return { - field: `node.network.${sortField.field}`, - direction: sortField.direction, - }; - case DomainsFields.uniqueIpCount: - return { - field: `node.${flowTarget}.${sortField.field}`, - direction: sortField.direction, - }; - default: - return { - field: 'node.network.bytes', - direction: Direction.desc, - }; - } -}; - -const getSortFromString = (sortField: string): DomainsFields => { - switch (sortField) { - case DomainsFields.domainName.valueOf(): - return DomainsFields.domainName; - case DomainsFields.bytes.valueOf(): - return DomainsFields.bytes; - case DomainsFields.packets.valueOf(): - return DomainsFields.packets; - case DomainsFields.uniqueIpCount.valueOf(): - return DomainsFields.uniqueIpCount; - default: - return DomainsFields.bytes; - } -}; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/mock.ts b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/mock.ts deleted file mode 100644 index 624db09f03943..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/mock.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { DomainsData } from '../../../../graphql/types'; - -export const mockDomainsData: DomainsData = { - totalCount: 2, - edges: [ - { - node: { - source: null, - destination: { - uniqueIpCount: 96, - domainName: 'samsungtv-kitchen.iot.sr.local.example.com', - firstSeen: null, - lastSeen: null, - }, - network: { - bytes: 1054651765, - direction: [], - packets: 707990, - }, - }, - cursor: { - value: 'samsungtv-kitchen.iot.sr.local.example.com', - }, - }, - { - node: { - source: null, - destination: { - uniqueIpCount: 6, - domainName: '.row.exmaple.com', - firstSeen: null, - lastSeen: null, - }, - network: { - bytes: 0, - direction: [], - packets: 0, - }, - }, - cursor: { - value: 'row.exmaple.com', - }, - }, - { - node: { - source: null, - destination: { - uniqueIpCount: 1, - domainName: '10.10.10.10', - firstSeen: null, - lastSeen: null, - }, - network: { - bytes: 0, - direction: [], - packets: 0, - }, - }, - cursor: { - value: '10.10.10.10', - }, - }, - ], - pageInfo: { - activePage: 1, - fakeTotalCount: 50, - showMorePagesIndicator: true, - }, -}; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/translations.ts b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/translations.ts deleted file mode 100644 index b702eb0578337..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/translations.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export const DOMAINS = i18n.translate('xpack.siem.network.ipDetails.domainsTable.domainsTitle', { - defaultMessage: 'Domains', -}); - -export const UNIT = (totalCount: number) => - i18n.translate('xpack.siem.network.ipDetails.domainsTable.unit', { - values: { totalCount }, - defaultMessage: `{totalCount, plural, =1 {domain} other {domains}}`, - }); - -// Columns -export const DOMAIN_NAME = i18n.translate( - 'xpack.siem.network.ipDetails.domainsTable.columns.domainNameTitle', - { - defaultMessage: 'Domain', - } -); - -export const DIRECTION = i18n.translate( - 'xpack.siem.network.ipDetails.domainsTable.columns.directionTitle', - { - defaultMessage: 'Direction', - } -); - -export const BYTES = i18n.translate( - 'xpack.siem.network.ipDetails.domainsTable.columns.bytesTitle', - { - defaultMessage: 'Bytes', - } -); - -export const PACKETS = i18n.translate( - 'xpack.siem.network.ipDetails.domainsTable.columns.packetsTitle', - { - defaultMessage: 'Packets', - } -); - -export const UNIQUE_DESTINATIONS = i18n.translate( - 'xpack.siem.network.ipDetails.domainsTable.columns.uniqueDestinationsTitle', - { - defaultMessage: 'Unique destinations', - } -); - -export const UNIQUE_SOURCES = i18n.translate( - 'xpack.siem.network.ipDetails.domainsTable.columns.uniqueSourcesTitle', - { - defaultMessage: 'Unique sources', - } -); - -export const UNIQUE_CLIENTS = i18n.translate( - 'xpack.siem.network.ipDetails.domainsTable.columns.uniqueClientsTitle', - { - defaultMessage: 'Unique servers', - } -); - -export const UNIQUE_SERVERS = i18n.translate( - 'xpack.siem.network.ipDetails.domainsTable.columns.uniqueServersTitle', - { - defaultMessage: 'Unique clients', - } -); - -export const LAST_SEEN = i18n.translate( - 'xpack.siem.network.ipDetails.domainsTable.columns.lastSeenTitle', - { - defaultMessage: 'Last seen', - } -); - -export const FIRST_LAST_SEEN_TOOLTIP = i18n.translate( - 'xpack.siem.network.ipDetails.domainsTable.columns.firstLastSeenToolTip', - { - defaultMessage: 'Relative to the selected date range', - } -); - -// Row Select -export const ROWS_5 = i18n.translate('xpack.siem.network.ipDetails.domainsTable.rows', { - values: { numRows: 5 }, - defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}', -}); - -export const ROWS_10 = i18n.translate('xpack.siem.network.ipDetails.domainsTable.rows', { - values: { numRows: 10 }, - defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}', -}); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/__snapshots__/index.test.tsx.snap index bcd7e6a0461a0..498c620312a3a 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/__snapshots__/index.test.tsx.snap @@ -1,6 +1,200 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`NetworkTopNFlow Table Component rendering it renders the default NetworkTopNFlow table 1`] = ` +exports[`NetworkTopNFlow Table Component rendering it renders the default NetworkTopNFlow table on the IP Details page 1`] = ` + +`; + +exports[`NetworkTopNFlow Table Component rendering it renders the default NetworkTopNFlow table on the Network page 1`] = ` ]; +export type NetworkTopNFlowColumnsIpDetails = [ + Columns, + Columns, + Columns, + Columns, + Columns, + Columns +]; + export const getNetworkTopNFlowColumns = ( indexPattern: StaticIndexPattern, - flowTarget: FlowTargetNew, + flowTarget: FlowTargetSourceDest, type: networkModel.NetworkType, tableId: string ): NetworkTopNFlowColumns => [ @@ -211,7 +220,7 @@ export const getNetworkTopNFlowColumns = ( { align: 'right', field: `node.${flowTarget}.${getOppositeField(flowTarget)}_ips`, - name: flowTarget === FlowTargetNew.source ? i18n.DESTINATION_IPS : i18n.SOURCE_IPS, + name: flowTarget === FlowTargetSourceDest.source ? i18n.DESTINATION_IPS : i18n.SOURCE_IPS, sortable: true, render: ips => { if (ips != null) { @@ -223,5 +232,24 @@ export const getNetworkTopNFlowColumns = ( }, ]; -const getOppositeField = (flowTarget: FlowTargetNew): FlowTargetNew => - flowTarget === FlowTargetNew.source ? FlowTargetNew.destination : FlowTargetNew.source; +export const getNFlowColumnsCurated = ( + indexPattern: StaticIndexPattern, + flowTarget: FlowTargetSourceDest, + type: networkModel.NetworkType, + tableId: string +): NetworkTopNFlowColumns | NetworkTopNFlowColumnsIpDetails => { + const columns = getNetworkTopNFlowColumns(indexPattern, flowTarget, type, tableId); + + // Columns to exclude from host details pages + if (type === networkModel.NetworkType.details) { + columns.pop(); + return columns; + } + + return columns; +}; + +const getOppositeField = (flowTarget: FlowTargetSourceDest): FlowTargetSourceDest => + flowTarget === FlowTargetSourceDest.source + ? FlowTargetSourceDest.destination + : FlowTargetSourceDest.source; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.test.tsx index d1d3a6a8c462c..aad28521b1c45 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.test.tsx @@ -11,7 +11,7 @@ import * as React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; import { Provider as ReduxStoreProvider } from 'react-redux'; -import { FlowTargetNew } from '../../../../graphql/types'; +import { FlowTargetSourceDest } from '../../../../graphql/types'; import { apolloClientObservable, mockGlobalState, @@ -34,13 +34,13 @@ describe('NetworkTopNFlow Table Component', () => { }); describe('rendering', () => { - test('it renders the default NetworkTopNFlow table', () => { + test('it renders the default NetworkTopNFlow table on the Network page', () => { const wrapper = shallow( { expect(toJson(wrapper)).toMatchSnapshot(); }); + + test('it renders the default NetworkTopNFlow table on the IP Details page', () => { + const wrapper = shallow( + + + + ); + + expect(toJson(wrapper)).toMatchSnapshot(); + }); }); describe('Sorting on Table', () => { @@ -69,7 +95,7 @@ describe('NetworkTopNFlow Table Component', () => { void; @@ -46,7 +47,12 @@ interface NetworkTopNFlowTableReduxProps { } interface NetworkTopNFlowTableDispatchProps { - updateTableActivePage: ActionCreator<{ + setIpDetailsTablesActivePageToZero: ActionCreator; + updateIpDetailsTableActivePage: ActionCreator<{ + activePage: number; + tableType: networkModel.IpDetailsTableType; + }>; + updateNetworkPageTableActivePage: ActionCreator<{ activePage: number; tableType: networkModel.NetworkTableType; }>; @@ -87,18 +93,26 @@ const NetworkTopNFlowTableComponent = React.memo( flowTargeted, id, indexPattern, + ip, isInspect, limit, loading, loadPage, + setIpDetailsTablesActivePageToZero, showMorePagesIndicator, topNFlowSort, totalCount, type, + updateIpDetailsTableActivePage, + updateNetworkPageTableActivePage, updateTopNFlowLimit, updateTopNFlowSort, - updateTableActivePage, }) => { + useEffect(() => { + if (ip && activePage !== 0) { + setIpDetailsTablesActivePageToZero(null); + } + }, [ip]); const onChange = (criteria: Criteria, tableType: networkModel.TopNTableType) => { if (criteria.sort != null) { const splitField = criteria.sort.field.split('.'); @@ -120,14 +134,23 @@ const NetworkTopNFlowTableComponent = React.memo( }; let tableType: networkModel.TopNTableType; - let headerTitle: string; - - if (flowTargeted === FlowTargetNew.source) { - headerTitle = i18n.SOURCE_IP; - tableType = networkModel.NetworkTableType.topNFlowSource; + const headerTitle: string = + flowTargeted === FlowTargetSourceDest.source ? i18n.SOURCE_IP : i18n.DESTINATION_IP; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let updateTableActivePage: any; + if (type === networkModel.NetworkType.page) { + tableType = + flowTargeted === FlowTargetSourceDest.source + ? networkModel.NetworkTableType.topNFlowSource + : networkModel.NetworkTableType.topNFlowDestination; + updateTableActivePage = updateNetworkPageTableActivePage; } else { - headerTitle = i18n.DESTINATION_IP; - tableType = networkModel.NetworkTableType.topNFlowDestination; + tableType = + flowTargeted === FlowTargetSourceDest.source + ? networkModel.IpDetailsTableType.topNFlowSource + : networkModel.IpDetailsTableType.topNFlowDestination; + updateTableActivePage = updateIpDetailsTableActivePage; } const field = @@ -139,12 +162,7 @@ const NetworkTopNFlowTableComponent = React.memo( return ( ( NetworkTopNFlowTableComponent.displayName = 'NetworkTopNFlowTableComponent'; const mapStateToProps = (state: State, ownProps: OwnProps) => - networkSelectors.topNFlowSelector(ownProps.flowTargeted); + networkSelectors.topNFlowSelector(ownProps.flowTargeted, ownProps.type); export const NetworkTopNFlowTable = connect( mapStateToProps, { + setIpDetailsTablesActivePageToZero: networkActions.setIpDetailsTablesActivePageToZero, updateTopNFlowLimit: networkActions.updateTopNFlowLimit, updateTopNFlowSort: networkActions.updateTopNFlowSort, - updateTableActivePage: networkActions.updateNetworkPageTableActivePage, + updateNetworkPageTableActivePage: networkActions.updateNetworkPageTableActivePage, + updateIpDetailsTableActivePage: networkActions.updateIpDetailsTableActivePage, } )(NetworkTopNFlowTableComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/mock.ts b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/mock.ts index 9e7cb9e8b4b6d..9ef63bf6d3167 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/mock.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/mock.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { NetworkTopNFlowData, FlowTarget } from '../../../../graphql/types'; +import { NetworkTopNFlowData, FlowTargetSourceDest } from '../../../../graphql/types'; export const mockData: { NetworkTopNFlow: NetworkTopNFlowData } = { NetworkTopNFlow: { @@ -30,7 +30,7 @@ export const mockData: { NetworkTopNFlow: NetworkTopNFlowData } = { region_iso_code: ['US-CA'], region_name: ['California'], }, - flowTarget: FlowTarget.source, + flowTarget: FlowTargetSourceDest.source, }, }, destination: null, @@ -63,7 +63,7 @@ export const mockData: { NetworkTopNFlow: NetworkTopNFlowData } = { region_iso_code: ['MY-10'], region_name: ['Selangor'], }, - flowTarget: FlowTarget.source, + flowTarget: FlowTargetSourceDest.source, }, }, destination: null, diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx index 257ee03c944bf..1529648b7133e 100644 --- a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx @@ -21,10 +21,12 @@ import styled, { css } from 'styled-components'; import { Direction } from '../../graphql/types'; import { AuthTableColumns } from '../page/hosts/authentications_table'; -import { DomainsColumns } from '../page/network/domains_table/columns'; import { HostsTableColumns } from '../page/hosts/hosts_table'; import { NetworkDnsColumns } from '../page/network/network_dns_table/columns'; -import { NetworkTopNFlowColumns } from '../page/network/network_top_n_flow_table/columns'; +import { + NetworkTopNFlowColumns, + NetworkTopNFlowColumnsIpDetails, +} from '../page/network/network_top_n_flow_table/columns'; import { TlsColumns } from '../page/network/tls_table/columns'; import { UncommonProcessTableColumns } from '../page/hosts/uncommon_process_table'; import { UsersColumns } from '../page/network/users_table/columns'; @@ -63,12 +65,11 @@ declare type HostsTableColumnsTest = [ declare type BasicTableColumns = | AuthTableColumns - | DomainsColumns - | DomainsColumns | HostsTableColumns | HostsTableColumnsTest | NetworkDnsColumns | NetworkTopNFlowColumns + | NetworkTopNFlowColumnsIpDetails | TlsColumns | UncommonProcessTableColumns | UsersColumns; diff --git a/x-pack/legacy/plugins/siem/public/containers/domains/index.gql_query.ts b/x-pack/legacy/plugins/siem/public/containers/domains/index.gql_query.ts deleted file mode 100644 index 8266a83bcad5e..0000000000000 --- a/x-pack/legacy/plugins/siem/public/containers/domains/index.gql_query.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -export const domainsQuery = gql` - query GetDomainsQuery( - $sourceId: ID! - $filterQuery: String - $flowDirection: FlowDirection! - $flowTarget: FlowTarget! - $ip: String! - $pagination: PaginationInputPaginated! - $sort: DomainsSortField! - $timerange: TimerangeInput! - $defaultIndex: [String!]! - $inspect: Boolean! - ) { - source(id: $sourceId) { - id - Domains( - filterQuery: $filterQuery - flowDirection: $flowDirection - flowTarget: $flowTarget - ip: $ip - pagination: $pagination - sort: $sort - timerange: $timerange - defaultIndex: $defaultIndex - ) { - totalCount - edges { - node { - source { - uniqueIpCount - domainName - firstSeen - lastSeen - } - destination { - uniqueIpCount - domainName - firstSeen - lastSeen - } - network { - bytes - direction - packets - } - } - cursor { - value - } - } - pageInfo { - activePage - fakeTotalCount - showMorePagesIndicator - } - inspect @include(if: $inspect) { - dsl - response - } - } - } - } -`; diff --git a/x-pack/legacy/plugins/siem/public/containers/domains/index.tsx b/x-pack/legacy/plugins/siem/public/containers/domains/index.tsx deleted file mode 100644 index 8aca3bc2086ac..0000000000000 --- a/x-pack/legacy/plugins/siem/public/containers/domains/index.tsx +++ /dev/null @@ -1,175 +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 { getOr } from 'lodash/fp'; -import React from 'react'; -import { Query } from 'react-apollo'; -import { connect } from 'react-redux'; - -import chrome from 'ui/chrome'; -import { DEFAULT_INDEX_KEY } from '../../../common/constants'; -import { - DomainsEdges, - DomainsSortField, - GetDomainsQuery, - FlowDirection, - FlowTarget, - PageInfoPaginated, -} from '../../graphql/types'; -import { inputsModel, networkModel, networkSelectors, State, inputsSelectors } from '../../store'; -import { createFilter, getDefaultFetchPolicy } from '../helpers'; -import { generateTablePaginationOptions } from '../../components/paginated_table/helpers'; -import { QueryTemplatePaginated, QueryTemplatePaginatedProps } from '../query_template_paginated'; -import { domainsQuery } from './index.gql_query'; - -const ID = 'domainsQuery'; - -export interface DomainsArgs { - domains: DomainsEdges[]; - id: string; - inspect: inputsModel.InspectQuery; - isInspected: boolean; - loading: boolean; - loadPage: (newActivePage: number) => void; - pageInfo: PageInfoPaginated; - refetch: inputsModel.Refetch; - totalCount: number; -} - -export interface OwnProps extends QueryTemplatePaginatedProps { - children: (args: DomainsArgs) => React.ReactNode; - flowTarget: FlowTarget; - ip: string; - type: networkModel.NetworkType; -} - -export interface DomainsComponentReduxProps { - activePage: number; - domainsSortField: DomainsSortField; - flowDirection: FlowDirection; - isInspected: boolean; - limit: number; -} - -type DomainsProps = OwnProps & DomainsComponentReduxProps; - -class DomainsComponentQuery extends QueryTemplatePaginated< - DomainsProps, - GetDomainsQuery.Query, - GetDomainsQuery.Variables -> { - public render() { - const { - activePage, - children, - domainsSortField, - endDate, - filterQuery, - flowDirection, - flowTarget, - id = ID, - ip, - isInspected, - limit, - skip, - sourceId, - startDate, - } = this.props; - const variables: GetDomainsQuery.Variables = { - defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY), - filterQuery: createFilter(filterQuery), - flowDirection, - flowTarget, - inspect: isInspected, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort: domainsSortField, - sourceId, - timerange: { - interval: '12h', - from: startDate!, - to: endDate!, - }, - }; - return ( - - query={domainsQuery} - fetchPolicy={getDefaultFetchPolicy()} - notifyOnNetworkStatusChange - skip={skip} - variables={{ - defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY), - filterQuery: createFilter(filterQuery), - flowDirection, - flowTarget, - inspect: isInspected, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort: domainsSortField, - sourceId, - timerange: { - interval: '12h', - from: startDate!, - to: endDate!, - }, - }} - > - {({ data, loading, fetchMore, networkStatus, refetch }) => { - const domains = getOr([], `source.Domains.edges`, data); - this.setFetchMore(fetchMore); - this.setFetchMoreOptions((newActivePage: number) => ({ - variables: { - pagination: generateTablePaginationOptions(newActivePage, limit), - }, - updateQuery: (prev, { fetchMoreResult }) => { - if (!fetchMoreResult) { - return prev; - } - return { - ...fetchMoreResult, - source: { - ...fetchMoreResult.source, - Domains: { - ...fetchMoreResult.source.Domains, - edges: [...fetchMoreResult.source.Domains.edges], - }, - }, - }; - }, - })); - const isLoading = this.isItAValidLoading(loading, variables, networkStatus); - return children({ - domains, - id, - inspect: getOr(null, 'source.Domains.inspect', data), - isInspected, - loading: isLoading, - loadPage: this.wrappedLoadMore, - pageInfo: getOr({}, 'source.Domains.pageInfo', data), - refetch: this.memoizedRefetchQuery(variables, limit, refetch), - totalCount: getOr(-1, 'source.Domains.totalCount', data), - }); - }} - - ); - } -} - -const makeMapStateToProps = () => { - const getDomainsSelector = networkSelectors.domainsSelector(); - const getQuery = inputsSelectors.globalQueryByIdSelector(); - const mapStateToProps = (state: State, { id = ID }: OwnProps) => { - const { isInspected } = getQuery(state, id); - return { - ...getDomainsSelector(state), - isInspected, - }; - }; - - return mapStateToProps; -}; - -export const DomainsQuery = connect(makeMapStateToProps)(DomainsComponentQuery); diff --git a/x-pack/legacy/plugins/siem/public/containers/network_top_n_flow/index.gql_query.ts b/x-pack/legacy/plugins/siem/public/containers/network_top_n_flow/index.gql_query.ts index 3b15cea33ef31..81a94bc94652b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/network_top_n_flow/index.gql_query.ts +++ b/x-pack/legacy/plugins/siem/public/containers/network_top_n_flow/index.gql_query.ts @@ -9,10 +9,11 @@ import gql from 'graphql-tag'; export const networkTopNFlowQuery = gql` query GetNetworkTopNFlowQuery( $sourceId: ID! + $ip: String $filterQuery: String $pagination: PaginationInputPaginated! $sort: NetworkTopNFlowSortField! - $flowTarget: FlowTargetNew! + $flowTarget: FlowTargetSourceDest! $timerange: TimerangeInput! $defaultIndex: [String!]! $inspect: Boolean! @@ -22,6 +23,7 @@ export const networkTopNFlowQuery = gql` NetworkTopNFlow( filterQuery: $filterQuery flowTarget: $flowTarget + ip: $ip pagination: $pagination sort: $sort timerange: $timerange diff --git a/x-pack/legacy/plugins/siem/public/containers/network_top_n_flow/index.tsx b/x-pack/legacy/plugins/siem/public/containers/network_top_n_flow/index.tsx index 6b7864d48ed08..eba9c5640fb58 100644 --- a/x-pack/legacy/plugins/siem/public/containers/network_top_n_flow/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/network_top_n_flow/index.tsx @@ -12,7 +12,7 @@ import { connect } from 'react-redux'; import chrome from 'ui/chrome'; import { DEFAULT_INDEX_KEY } from '../../../common/constants'; import { - FlowTargetNew, + FlowTargetSourceDest, GetNetworkTopNFlowQuery, NetworkTopNFlowEdges, NetworkTopNFlowSortField, @@ -28,6 +28,7 @@ const ID = 'networkTopNFlowQuery'; export interface NetworkTopNFlowArgs { id: string; + ip?: string; inspect: inputsModel.InspectQuery; isInspected: boolean; loading: boolean; @@ -40,7 +41,8 @@ export interface NetworkTopNFlowArgs { export interface OwnProps extends QueryTemplatePaginatedProps { children: (args: NetworkTopNFlowArgs) => React.ReactNode; - flowTarget: FlowTargetNew; + flowTarget: FlowTargetSourceDest; + ip?: string; type: networkModel.NetworkType; } @@ -66,6 +68,7 @@ class NetworkTopNFlowComponentQuery extends QueryTemplatePaginated< flowTarget, filterQuery, id = `${ID}-${flowTarget}`, + ip, isInspected, limit, skip, @@ -78,6 +81,7 @@ class NetworkTopNFlowComponentQuery extends QueryTemplatePaginated< filterQuery: createFilter(filterQuery), flowTarget, inspect: isInspected, + ip, pagination: generateTablePaginationOptions(activePage, limit), sort: topNFlowSort, sourceId, @@ -136,8 +140,11 @@ class NetworkTopNFlowComponentQuery extends QueryTemplatePaginated< } } -const mapStateToProps = (state: State, { flowTarget, id = `${ID}-${flowTarget}` }: OwnProps) => { - const getNetworkTopNFlowSelector = networkSelectors.topNFlowSelector(flowTarget); +const mapStateToProps = ( + state: State, + { flowTarget, id = `${ID}-${flowTarget}`, type }: OwnProps +) => { + const getNetworkTopNFlowSelector = networkSelectors.topNFlowSelector(flowTarget, type); const getQuery = inputsSelectors.globalQueryByIdSelector(); const { isInspected } = getQuery(state, id); return { diff --git a/x-pack/legacy/plugins/siem/public/graphql/introspection.json b/x-pack/legacy/plugins/siem/public/graphql/introspection.json index 9173bbe5295f7..b8bdf27bed7fa 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/siem/public/graphql/introspection.json @@ -1193,113 +1193,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "Domains", - "description": "", - "args": [ - { - "name": "filterQuery", - "description": "", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": null - }, - { - "name": "id", - "description": "", - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "defaultValue": null - }, - { - "name": "ip", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "pagination", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PaginationInputPaginated", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "sort", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "INPUT_OBJECT", "name": "DomainsSortField", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "flowDirection", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "ENUM", "name": "FlowDirection", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "flowTarget", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "ENUM", "name": "FlowTarget", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "timerange", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "INPUT_OBJECT", "name": "TimerangeInput", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "defaultIndex", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "DomainsData", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "Tls", "description": "", @@ -1665,13 +1558,19 @@ "type": { "kind": "SCALAR", "name": "String", "ofType": null }, "defaultValue": null }, + { + "name": "ip", + "description": "", + "type": { "kind": "SCALAR", "name": "String", "ofType": null }, + "defaultValue": null + }, { "name": "flowTarget", "description": "", "type": { "kind": "NON_NULL", "name": null, - "ofType": { "kind": "ENUM", "name": "FlowTargetNew", "ofType": null } + "ofType": { "kind": "ENUM", "name": "FlowTargetSourceDest", "ofType": null } }, "defaultValue": null }, @@ -6384,7 +6283,7 @@ }, { "kind": "INPUT_OBJECT", - "name": "DomainsSortField", + "name": "TlsSortField", "description": "", "fields": null, "inputFields": [ @@ -6394,7 +6293,7 @@ "type": { "kind": "NON_NULL", "name": null, - "ofType": { "kind": "ENUM", "name": "DomainsFields", "ofType": null } + "ofType": { "kind": "ENUM", "name": "TlsFields", "ofType": null } }, "defaultValue": null }, @@ -6415,60 +6314,13 @@ }, { "kind": "ENUM", - "name": "DomainsFields", - "description": "", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "domainName", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "direction", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { "name": "bytes", "description": "", "isDeprecated": false, "deprecationReason": null }, - { - "name": "packets", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "uniqueIpCount", - "description": "", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "FlowDirection", + "name": "TlsFields", "description": "", "fields": null, "inputFields": null, "interfaces": null, "enumValues": [ - { - "name": "uniDirectional", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "biDirectional", - "description": "", - "isDeprecated": false, - "deprecationReason": null - } + { "name": "_id", "description": "", "isDeprecated": false, "deprecationReason": null } ], "possibleTypes": null }, @@ -6494,7 +6346,7 @@ }, { "kind": "OBJECT", - "name": "DomainsData", + "name": "TlsData", "description": "", "fields": [ { @@ -6510,7 +6362,7 @@ "ofType": { "kind": "NON_NULL", "name": null, - "ofType": { "kind": "OBJECT", "name": "DomainsEdges", "ofType": null } + "ofType": { "kind": "OBJECT", "name": "TlsEdges", "ofType": null } } } }, @@ -6557,7 +6409,7 @@ }, { "kind": "OBJECT", - "name": "DomainsEdges", + "name": "TlsEdges", "description": "", "fields": [ { @@ -6567,7 +6419,7 @@ "type": { "kind": "NON_NULL", "name": null, - "ofType": { "kind": "OBJECT", "name": "DomainsNode", "ofType": null } + "ofType": { "kind": "OBJECT", "name": "TlsNode", "ofType": null } }, "isDeprecated": false, "deprecationReason": null @@ -6592,7 +6444,7 @@ }, { "kind": "OBJECT", - "name": "DomainsNode", + "name": "TlsNode", "description": "", "fields": [ { @@ -6612,42 +6464,82 @@ "deprecationReason": null }, { - "name": "source", + "name": "alternativeNames", "description": "", "args": [], - "type": { "kind": "OBJECT", "name": "DomainsItem", "ofType": null }, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } + } + }, "isDeprecated": false, "deprecationReason": null }, { - "name": "destination", + "name": "notAfter", "description": "", "args": [], - "type": { "kind": "OBJECT", "name": "DomainsItem", "ofType": null }, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } + } + }, "isDeprecated": false, "deprecationReason": null }, { - "name": "client", + "name": "commonNames", "description": "", "args": [], - "type": { "kind": "OBJECT", "name": "DomainsItem", "ofType": null }, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } + } + }, "isDeprecated": false, "deprecationReason": null }, { - "name": "server", + "name": "ja3", "description": "", "args": [], - "type": { "kind": "OBJECT", "name": "DomainsItem", "ofType": null }, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } + } + }, "isDeprecated": false, "deprecationReason": null }, { - "name": "network", + "name": "issuerNames", "description": "", "args": [], - "type": { "kind": "OBJECT", "name": "DomainsNetworkField", "ofType": null }, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } + } + }, "isDeprecated": false, "deprecationReason": null } @@ -6658,419 +6550,23 @@ "possibleTypes": null }, { - "kind": "OBJECT", - "name": "DomainsItem", + "kind": "INPUT_OBJECT", + "name": "UsersSortField", "description": "", - "fields": [ - { - "name": "uniqueIpCount", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "domainName", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, + "fields": null, + "inputFields": [ { - "name": "firstSeen", + "name": "field", "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Date", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "ENUM", "name": "UsersFields", "ofType": null } + }, + "defaultValue": null }, { - "name": "lastSeen", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Date", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DomainsNetworkField", - "description": "", - "fields": [ - { - "name": "bytes", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "packets", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transport", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "direction", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "ENUM", "name": "NetworkDirectionEcs", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "NetworkDirectionEcs", - "description": "", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "inbound", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "outbound", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "internal", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "external", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "incoming", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "outgoing", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "listening", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { "name": "unknown", "description": "", "isDeprecated": false, "deprecationReason": null } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TlsSortField", - "description": "", - "fields": null, - "inputFields": [ - { - "name": "field", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "ENUM", "name": "TlsFields", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "direction", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "ENUM", "name": "Direction", "ofType": null } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "TlsFields", - "description": "", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { "name": "_id", "description": "", "isDeprecated": false, "deprecationReason": null } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TlsData", - "description": "", - "fields": [ - { - "name": "edges", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "TlsEdges", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "totalCount", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "PageInfoPaginated", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "inspect", - "description": "", - "args": [], - "type": { "kind": "OBJECT", "name": "Inspect", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TlsEdges", - "description": "", - "fields": [ - { - "name": "node", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "TlsNode", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cursor", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "CursorType", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TlsNode", - "description": "", - "fields": [ - { - "name": "_id", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "Date", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "alternativeNames", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "notAfter", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "commonNames", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ja3", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "issuerNames", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UsersSortField", - "description": "", - "fields": null, - "inputFields": [ - { - "name": "field", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "ENUM", "name": "UsersFields", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "direction", + "name": "direction", "description": "", "type": { "kind": "NON_NULL", @@ -7690,7 +7186,7 @@ }, { "kind": "ENUM", - "name": "FlowTargetNew", + "name": "FlowTargetSourceDest", "description": "", "fields": null, "inputFields": null, @@ -8025,7 +7521,7 @@ "name": "flowTarget", "description": "", "args": [], - "type": { "kind": "ENUM", "name": "FlowTarget", "ofType": null }, + "type": { "kind": "ENUM", "name": "FlowTargetSourceDest", "ofType": null }, "isDeprecated": false, "deprecationReason": null } @@ -11283,6 +10779,83 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "ENUM", + "name": "NetworkDirectionEcs", + "description": "", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "inbound", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "outbound", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "internal", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "external", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "incoming", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "outgoing", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "listening", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "unknown", "description": "", "isDeprecated": false, "deprecationReason": null } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "FlowDirection", + "description": "", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "uniDirectional", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "biDirectional", + "description": "", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "FavoriteTimelineInput", diff --git a/x-pack/legacy/plugins/siem/public/graphql/types.ts b/x-pack/legacy/plugins/siem/public/graphql/types.ts index 2d82dcb7b30f3..7514259caa2a7 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/public/graphql/types.ts @@ -67,12 +67,6 @@ export interface HostsSortField { direction: Direction; } -export interface DomainsSortField { - field: DomainsFields; - - direction: Direction; -} - export interface TlsSortField { field: TlsFields; @@ -245,17 +239,8 @@ export enum HostsFields { lastSeen = 'lastSeen', } -export enum DomainsFields { - domainName = 'domainName', - direction = 'direction', - bytes = 'bytes', - packets = 'packets', - uniqueIpCount = 'uniqueIpCount', -} - -export enum FlowDirection { - uniDirectional = 'uniDirectional', - biDirectional = 'biDirectional', +export enum TlsFields { + _id = '_id', } export enum FlowTarget { @@ -265,27 +250,12 @@ export enum FlowTarget { source = 'source', } -export enum NetworkDirectionEcs { - inbound = 'inbound', - outbound = 'outbound', - internal = 'internal', - external = 'external', - incoming = 'incoming', - outgoing = 'outgoing', - listening = 'listening', - unknown = 'unknown', -} - -export enum TlsFields { - _id = '_id', -} - export enum UsersFields { name = 'name', count = 'count', } -export enum FlowTargetNew { +export enum FlowTargetSourceDest { destination = 'destination', source = 'source', } @@ -313,6 +283,22 @@ export enum SortFieldTimeline { created = 'created', } +export enum NetworkDirectionEcs { + inbound = 'inbound', + outbound = 'outbound', + internal = 'internal', + external = 'external', + incoming = 'incoming', + outgoing = 'outgoing', + listening = 'listening', + unknown = 'unknown', +} + +export enum FlowDirection { + uniDirectional = 'uniDirectional', + biDirectional = 'biDirectional', +} + export type ToStringArray = string[]; export type Date = string; @@ -431,8 +417,6 @@ export interface Source { IpOverview?: Maybe; - Domains: DomainsData; - Tls: TlsData; Users: UsersData; @@ -1328,58 +1312,6 @@ export interface AutonomousSystemOrganization { name?: Maybe; } -export interface DomainsData { - edges: DomainsEdges[]; - - totalCount: number; - - pageInfo: PageInfoPaginated; - - inspect?: Maybe; -} - -export interface DomainsEdges { - node: DomainsNode; - - cursor: CursorType; -} - -export interface DomainsNode { - _id?: Maybe; - - timestamp?: Maybe; - - source?: Maybe; - - destination?: Maybe; - - client?: Maybe; - - server?: Maybe; - - network?: Maybe; -} - -export interface DomainsItem { - uniqueIpCount?: Maybe; - - domainName?: Maybe; - - firstSeen?: Maybe; - - lastSeen?: Maybe; -} - -export interface DomainsNetworkField { - bytes?: Maybe; - - packets?: Maybe; - - transport?: Maybe; - - direction?: Maybe; -} - export interface TlsData { edges: TlsEdges[]; @@ -1573,7 +1505,7 @@ export interface AutonomousSystemItem { export interface GeoItem { geo?: Maybe; - flowTarget?: Maybe; + flowTarget?: Maybe; } export interface TopNFlowItemDestination { @@ -2068,25 +2000,6 @@ export interface IpOverviewSourceArgs { defaultIndex: string[]; } -export interface DomainsSourceArgs { - filterQuery?: Maybe; - - id?: Maybe; - - ip: string; - - pagination: PaginationInputPaginated; - - sort: DomainsSortField; - - flowDirection: FlowDirection; - - flowTarget: FlowTarget; - - timerange: TimerangeInput; - - defaultIndex: string[]; -} export interface TlsSourceArgs { filterQuery?: Maybe; @@ -2153,7 +2066,9 @@ export interface NetworkTopNFlowSourceArgs { filterQuery?: Maybe; - flowTarget: FlowTargetNew; + ip?: Maybe; + + flowTarget: FlowTargetSourceDest; pagination: PaginationInputPaginated; @@ -2396,123 +2311,6 @@ export namespace GetAuthenticationsQuery { }; } -export namespace GetDomainsQuery { - export type Variables = { - sourceId: string; - filterQuery?: Maybe; - flowDirection: FlowDirection; - flowTarget: FlowTarget; - ip: string; - pagination: PaginationInputPaginated; - sort: DomainsSortField; - timerange: TimerangeInput; - defaultIndex: string[]; - inspect: boolean; - }; - - export type Query = { - __typename?: 'Query'; - - source: Source; - }; - - export type Source = { - __typename?: 'Source'; - - id: string; - - Domains: Domains; - }; - - export type Domains = { - __typename?: 'DomainsData'; - - totalCount: number; - - edges: Edges[]; - - pageInfo: PageInfo; - - inspect: Maybe; - }; - - export type Edges = { - __typename?: 'DomainsEdges'; - - node: Node; - - cursor: Cursor; - }; - - export type Node = { - __typename?: 'DomainsNode'; - - source: Maybe<_Source>; - - destination: Maybe; - - network: Maybe; - }; - - export type _Source = { - __typename?: 'DomainsItem'; - - uniqueIpCount: Maybe; - - domainName: Maybe; - - firstSeen: Maybe; - - lastSeen: Maybe; - }; - - export type Destination = { - __typename?: 'DomainsItem'; - - uniqueIpCount: Maybe; - - domainName: Maybe; - - firstSeen: Maybe; - - lastSeen: Maybe; - }; - - export type Network = { - __typename?: 'DomainsNetworkField'; - - bytes: Maybe; - - direction: Maybe; - - packets: Maybe; - }; - - export type Cursor = { - __typename?: 'CursorType'; - - value: Maybe; - }; - - export type PageInfo = { - __typename?: 'PageInfoPaginated'; - - activePage: number; - - fakeTotalCount: number; - - showMorePagesIndicator: boolean; - }; - - export type Inspect = { - __typename?: 'Inspect'; - - dsl: string[]; - - response: string[]; - }; -} - export namespace GetEventsOverTimeQuery { export type Variables = { sourceId: string; @@ -3276,10 +3074,11 @@ export namespace GetNetworkDnsQuery { export namespace GetNetworkTopNFlowQuery { export type Variables = { sourceId: string; + ip?: Maybe; filterQuery?: Maybe; pagination: PaginationInputPaginated; sort: NetworkTopNFlowSortField; - flowTarget: FlowTargetNew; + flowTarget: FlowTargetSourceDest; timerange: TimerangeInput; defaultIndex: string[]; inspect: boolean; @@ -3358,7 +3157,7 @@ export namespace GetNetworkTopNFlowQuery { geo: Maybe; - flowTarget: Maybe; + flowTarget: Maybe; }; export type Geo = { @@ -3406,7 +3205,7 @@ export namespace GetNetworkTopNFlowQuery { geo: Maybe<_Geo>; - flowTarget: Maybe; + flowTarget: Maybe; }; export type _Geo = { diff --git a/x-pack/legacy/plugins/siem/public/hooks/api/__mock__/api.tsx b/x-pack/legacy/plugins/siem/public/hooks/api/__mock__/api.tsx new file mode 100644 index 0000000000000..13f53cd34feb6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/hooks/api/__mock__/api.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexPatternSavedObject } from '../../types'; + +export const mockIndexPatternSavedObjects: IndexPatternSavedObject[] = [ + { + type: 'index-pattern', + id: '2d1fe420-eeee-11e9-ad95-4b5e687c2aee', + attributes: { + title: 'filebeat-*', + }, + updated_at: '2019-08-26T04:30:09.111Z', + version: 'WzE4LLwxXQ==', + }, + { + type: 'index-pattern', + id: '5463ec70-c7ba-ffff-ad95-4b5e687c2aee', + attributes: { + title: 'auditbeat-*', + }, + updated_at: '2019-08-26T04:31:12.934Z', + version: 'WzELLywxXQ==', + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/hooks/api/api.test.ts b/x-pack/legacy/plugins/siem/public/hooks/api/api.test.ts new file mode 100644 index 0000000000000..95825b7d4abda --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/hooks/api/api.test.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 fetchMock from 'fetch-mock'; +import { throwIfNotOk } from './api'; + +describe('api', () => { + afterEach(() => { + fetchMock.reset(); + }); + + describe('#throwIfNotOk', () => { + test('does a throw if it is given response that is not ok and the body is not parsable', async () => { + fetchMock.mock('http://example.com', 500); + const response = await fetch('http://example.com'); + await expect(throwIfNotOk(response)).rejects.toThrow('Network Error: Internal Server Error'); + }); + + test('does a throw and returns a body if it is parsable', async () => { + fetchMock.mock('http://example.com', { + status: 500, + body: { + statusCode: 500, + message: 'I am a custom message', + }, + }); + const response = await fetch('http://example.com'); + await expect(throwIfNotOk(response)).rejects.toThrow('I am a custom message'); + }); + + test('does NOT do a throw if it is given response is not ok', async () => { + fetchMock.mock('http://example.com', 200); + const response = await fetch('http://example.com'); + await expect(throwIfNotOk(response)).resolves.toEqual(undefined); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx b/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx new file mode 100644 index 0000000000000..b0493733a735b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/hooks/api/api.tsx @@ -0,0 +1,68 @@ +/* + * 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 chrome from 'ui/chrome'; + +import { useKibanaUiSetting } from '../../lib/settings/use_kibana_ui_setting'; +import { DEFAULT_KBN_VERSION } from '../../../common/constants'; +import * as i18n from '../translations'; +import { parseJsonFromBody, ToasterErrors } from '../../components/ml/api/throw_if_not_ok'; +import { IndexPatternResponse, IndexPatternSavedObject } from '../types'; + +const emptyIndexPattern: IndexPatternSavedObject[] = []; + +/** + * Fetches Configured Index Patterns from the Kibana saved objects API + * + * TODO: Refactor to context provider: https://github.com/elastic/siem-team/issues/448 + * + * @param headers + * @param signal + */ +export const getIndexPatterns = async ( + signal: AbortSignal, + headers?: Record +): Promise => { + const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); + const response = await fetch( + `${chrome.getBasePath()}/api/saved_objects/_find?type=index-pattern&fields=title&fields=type&per_page=10000`, + { + method: 'GET', + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': kbnVersion, + 'kbn-version': kbnVersion, + 'kbn-system-api': 'true', + ...headers, + }, + signal, + } + ); + await throwIfNotOk(response); + const results: IndexPatternResponse = await response.json(); + + if (results.saved_objects && Array.isArray(results.saved_objects)) { + return results.saved_objects; + } else { + return emptyIndexPattern; + } +}; + +export const throwIfNotOk = async (response: Response): Promise => { + if (!response.ok) { + const body = await parseJsonFromBody(response); + if (body != null && body.message) { + if (body.statusCode != null) { + throw new ToasterErrors([body.message, `${i18n.STATUS_CODE} ${body.statusCode}`]); + } else { + throw new ToasterErrors([body.message]); + } + } else { + throw new ToasterErrors([`${i18n.NETWORK_ERROR} ${response.statusText}`]); + } + } +}; diff --git a/x-pack/legacy/plugins/siem/public/hooks/api/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/hooks/api/helpers.test.tsx new file mode 100644 index 0000000000000..b02db810f72e1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/hooks/api/helpers.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getIndexPatternTitleIdMapping, getIndexPatternTitles } from './helpers'; +import { mockIndexPatternSavedObjects } from './__mock__/api'; + +describe('helpers', () => { + describe('getIndexPatternTitles', () => { + test('returns empty array when no index patterns are provided', () => { + const indexPatternTitles = getIndexPatternTitles([]); + expect(indexPatternTitles.length).toEqual(0); + }); + + test('returns titles when index patterns are provided', () => { + const indexPatternTitles = getIndexPatternTitles(mockIndexPatternSavedObjects); + expect(indexPatternTitles).toEqual(['filebeat-*', 'auditbeat-*']); + }); + }); + + describe('getIndexPatternTitleIdMapping', () => { + test('returns empty array when no index patterns are provided', () => { + const indexPatternTitleIdMapping = getIndexPatternTitleIdMapping([]); + expect(indexPatternTitleIdMapping.length).toEqual(0); + }); + + test('returns correct mapping when index patterns are provided', () => { + const indexPatternTitleIdMapping = getIndexPatternTitleIdMapping( + mockIndexPatternSavedObjects + ); + expect(indexPatternTitleIdMapping).toEqual([ + { id: '2d1fe420-eeee-11e9-ad95-4b5e687c2aee', title: 'filebeat-*' }, + { id: '5463ec70-c7ba-ffff-ad95-4b5e687c2aee', title: 'auditbeat-*' }, + ]); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/hooks/api/helpers.tsx b/x-pack/legacy/plugins/siem/public/hooks/api/helpers.tsx new file mode 100644 index 0000000000000..bcf3f1f3d3c08 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/hooks/api/helpers.tsx @@ -0,0 +1,31 @@ +/* + * 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 { IndexPatternSavedObject } from '../types'; + +/** + * Returns a string array of Index Pattern Titles + * + * @param indexPatterns IndexPatternSavedObject[] as provided from the useIndexPatterns() hook + */ +export const getIndexPatternTitles = (indexPatterns: IndexPatternSavedObject[]): string[] => + indexPatterns.reduce((acc, v) => [...acc, v.attributes.title], []); + +/** + * Returns a mapping of indexPatternTitle to indexPatternId + * + * @param indexPatterns IndexPatternSavedObject[] as provided from the useIndexPatterns() hook + */ +export const getIndexPatternTitleIdMapping = ( + indexPatterns: IndexPatternSavedObject[] +): Array<{ title: string; id: string }> => + indexPatterns.reduce>((acc, v) => { + if (v.attributes && v.attributes.title) { + return [...acc, { title: v.attributes.title, id: v.id }]; + } else { + return acc; + } + }, []); diff --git a/x-pack/legacy/plugins/siem/public/hooks/translations.ts b/x-pack/legacy/plugins/siem/public/hooks/translations.ts new file mode 100644 index 0000000000000..ba3ec40df466a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/hooks/translations.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const STATUS_CODE = i18n.translate( + 'xpack.siem.components.ml.api.errors.statusCodeFailureTitle', + { + defaultMessage: 'Status Code:', + } +); + +export const NETWORK_ERROR = i18n.translate( + 'xpack.siem.components.ml.api.errors.networkErrorFailureTitle', + { + defaultMessage: 'Network Error:', + } +); + +export const INDEX_PATTERN_FETCH_FAILURE = i18n.translate( + 'xpack.siem.components.mlPopup.hooks.errors.indexPatternFetchFailureTitle', + { + defaultMessage: 'Index pattern fetch failure', + } +); diff --git a/x-pack/legacy/plugins/siem/public/hooks/types.ts b/x-pack/legacy/plugins/siem/public/hooks/types.ts new file mode 100644 index 0000000000000..4d66d8e191235 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/hooks/types.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. + */ + +export interface IndexPatternSavedObject { + attributes: { + title: string; + }; + id: string; + type: string; + updated_at: string; + version: string; +} + +export interface IndexPatternResponse { + page: number; + per_page: number; + saved_objects: IndexPatternSavedObject[]; + total: number; +} diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_index_patterns.tsx b/x-pack/legacy/plugins/siem/public/hooks/use_index_patterns.tsx similarity index 65% rename from x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_index_patterns.tsx rename to x-pack/legacy/plugins/siem/public/hooks/use_index_patterns.tsx index 3f5f6f9c4e958..b7e899cc50f75 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_index_patterns.tsx +++ b/x-pack/legacy/plugins/siem/public/hooks/use_index_patterns.tsx @@ -6,24 +6,19 @@ import { useEffect, useState } from 'react'; -import { getIndexPatterns } from '../api'; -import { useStateToaster } from '../../toasters'; -import { errorToToaster } from '../../ml/api/error_to_toaster'; -import { useKibanaUiSetting } from '../../../lib/settings/use_kibana_ui_setting'; -import { DEFAULT_KBN_VERSION } from '../../../../common/constants'; +import { useStateToaster } from '../components/toasters'; +import { errorToToaster } from '../components/ml/api/error_to_toaster'; import * as i18n from './translations'; -import { IndexPatternSavedObject } from '../types'; +import { IndexPatternSavedObject } from '../components/ml_popover/types'; +import { getIndexPatterns } from './api/api'; type Return = [boolean, IndexPatternSavedObject[]]; -// TODO: Used by more than just ML now -- refactor to shared component https://github.com/elastic/siem-team/issues/448 - export const useIndexPatterns = (refreshToggle = false): Return => { const [indexPatterns, setIndexPatterns] = useState([]); const [isLoading, setIsLoading] = useState(true); const [, dispatchToaster] = useStateToaster(); - const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); useEffect(() => { let isSubscribed = true; @@ -32,12 +27,7 @@ export const useIndexPatterns = (refreshToggle = false): Return => { async function fetchIndexPatterns() { try { - const data = await getIndexPatterns( - { - 'kbn-version': kbnVersion, - }, - abortCtrl.signal - ); + const data = await getIndexPatterns(abortCtrl.signal); if (isSubscribed) { setIndexPatterns(data); diff --git a/x-pack/legacy/plugins/siem/public/mock/global_state.ts b/x-pack/legacy/plugins/siem/public/mock/global_state.ts index 83fa30c97145f..02f098cb7b9a9 100644 --- a/x-pack/legacy/plugins/siem/public/mock/global_state.ts +++ b/x-pack/legacy/plugins/siem/public/mock/global_state.ts @@ -7,8 +7,6 @@ import { DEFAULT_TIMELINE_WIDTH } from '../components/timeline/body/helpers'; import { Direction, - DomainsFields, - FlowDirection, FlowTarget, HostsFields, NetworkDnsFields, @@ -16,7 +14,7 @@ import { TlsFields, UsersFields, } from '../graphql/types'; -import { State } from '../store'; +import { networkModel, State } from '../store'; import { defaultHeaders } from './header'; import { @@ -71,17 +69,17 @@ export const mockGlobalState: State = { network: { page: { queries: { - topNFlowSource: { + [networkModel.NetworkTableType.topNFlowSource]: { activePage: 0, limit: 10, topNFlowSort: { field: NetworkTopNFlowFields.bytes_out, direction: Direction.desc }, }, - topNFlowDestination: { + [networkModel.NetworkTableType.topNFlowDestination]: { activePage: 0, limit: 10, topNFlowSort: { field: NetworkTopNFlowFields.bytes_out, direction: Direction.desc }, }, - dns: { + [networkModel.NetworkTableType.dns]: { activePage: 0, limit: 10, dnsSortField: { field: NetworkDnsFields.queryCount, direction: Direction.desc }, @@ -96,18 +94,22 @@ export const mockGlobalState: State = { filterQueryDraft: null, flowTarget: FlowTarget.source, queries: { - domains: { + [networkModel.IpDetailsTableType.topNFlowSource]: { activePage: 0, limit: 10, - flowDirection: FlowDirection.uniDirectional, - domainsSortField: { field: DomainsFields.bytes, direction: Direction.desc }, + topNFlowSort: { field: NetworkTopNFlowFields.bytes_out, direction: Direction.desc }, + }, + [networkModel.IpDetailsTableType.topNFlowDestination]: { + activePage: 0, + limit: 10, + topNFlowSort: { field: NetworkTopNFlowFields.bytes_out, direction: Direction.desc }, }, - tls: { + [networkModel.IpDetailsTableType.tls]: { activePage: 0, limit: 10, tlsSortField: { field: TlsFields._id, direction: Direction.desc }, }, - users: { + [networkModel.IpDetailsTableType.users]: { activePage: 0, limit: 10, usersSortField: { field: UsersFields.name, direction: Direction.asc }, diff --git a/x-pack/legacy/plugins/siem/public/pages/network/ip_details.tsx b/x-pack/legacy/plugins/siem/public/pages/network/ip_details.tsx index d815b37496bf1..80fcce20a4982 100644 --- a/x-pack/legacy/plugins/siem/public/pages/network/ip_details.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/network/ip_details.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import { EuiHorizontalRule, EuiSpacer, EuiFlexItem } from '@elastic/eui'; import { getOr } from 'lodash/fp'; import React from 'react'; import { connect } from 'react-redux'; @@ -17,23 +17,23 @@ import { HeaderPage } from '../../components/header_page'; import { LastEventTime } from '../../components/last_event_time'; import { getNetworkUrl } from '../../components/link_to/redirect_to_network'; import { manageQuery } from '../../components/page/manage_query'; -import { DomainsTable } from '../../components/page/network/domains_table'; import { FlowTargetSelectConnected } from '../../components/page/network/flow_target_select_connected'; import { IpOverview } from '../../components/page/network/ip_overview'; import { UsersTable } from '../../components/page/network/users_table'; import { TlsTable } from '../../components/page/network/tls_table'; -import { DomainsQuery } from '../../containers/domains'; import { IpOverviewQuery } from '../../containers/ip_overview'; import { indicesExistOrDataTemporarilyUnavailable, WithSource } from '../../containers/source'; import { TlsQuery } from '../../containers/tls'; import { UsersQuery } from '../../containers/users'; -import { LastEventIndexKey } from '../../graphql/types'; +import { FlowTargetSourceDest, LastEventIndexKey } from '../../graphql/types'; import { decodeIpv6 } from '../../lib/helpers'; import { networkModel, networkSelectors, State } from '../../store'; import { setAbsoluteRangeDatePicker as dispatchAbsoluteRangeDatePicker } from '../../store/inputs/actions'; import { NetworkKql } from './kql'; import { NetworkEmptyPage } from './network_empty_page'; +import { NetworkTopNFlowQuery } from '../../containers/network_top_n_flow'; +import { NetworkTopNFlowTable } from '../../components/page/network/network_top_n_flow_table'; import * as i18n from './translations'; import { IPDetailsComponentProps } from './types'; import { AnomalyTableProvider } from '../../components/ml/anomaly/anomaly_table_provider'; @@ -41,11 +41,12 @@ import { scoreIntervalToDateTime } from '../../components/ml/score/score_interva import { AnomaliesNetworkTable } from '../../components/ml/tables/anomalies_network_table'; import { networkToCriteria } from '../../components/ml/criteria/network_to_criteria'; import { SpyRoute } from '../../utils/route/spy_routes'; +import { ConditionalFlexGroup } from '../../pages/network/navigation/conditional_flex_group'; -const DomainsTableManage = manageQuery(DomainsTable); const TlsTableManage = manageQuery(TlsTable); const UsersTableManage = manageQuery(UsersTable); const IpOverviewManage = manageQuery(IpOverview); +const NetworkTopNFlowTableManage = manageQuery(NetworkTopNFlowTable); export const IPDetailsComponent = pure( ({ @@ -127,46 +128,92 @@ export const IPDetailsComponent = pure( - - {({ - id, - inspect, - isInspected, - domains, - totalCount, - pageInfo, - loading, - loadPage, - refetch, - }) => ( - + + - )} - + > + {({ + id, + inspect, + isInspected, + loading, + loadPage, + networkTopNFlow, + pageInfo, + refetch, + totalCount, + }) => ( + + )} + +
+ + + + {({ + id, + inspect, + isInspected, + loading, + loadPage, + networkTopNFlow, + pageInfo, + refetch, + totalCount, + }) => ( + + )} + + + diff --git a/x-pack/legacy/plugins/siem/public/pages/network/navigation/network_routes.tsx b/x-pack/legacy/plugins/siem/public/pages/network/navigation/network_routes.tsx index 08d9252357b37..0ed652804cc8e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/network/navigation/network_routes.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/network/navigation/network_routes.tsx @@ -8,7 +8,7 @@ import React, { useCallback } from 'react'; import { Route, Switch } from 'react-router-dom'; import { EuiFlexItem } from '@elastic/eui'; -import { FlowTargetNew } from '../../../graphql/types'; +import { FlowTargetSourceDest } from '../../../graphql/types'; import { scoreIntervalToDateTime } from '../../../components/ml/score/score_interval_to_datetime'; import { IPsQueryTabBody } from './ips_query_tab_body'; @@ -70,11 +70,11 @@ export const NetworkRoutes = ({ render={() => ( - + - + )} diff --git a/x-pack/legacy/plugins/siem/public/pages/network/navigation/types.ts b/x-pack/legacy/plugins/siem/public/pages/network/navigation/types.ts index 838aaea38608f..bcdc0ef7aa790 100644 --- a/x-pack/legacy/plugins/siem/public/pages/network/navigation/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/network/navigation/types.ts @@ -7,7 +7,7 @@ import { StaticIndexPattern } from 'ui/index_patterns'; import { NavTab } from '../../../components/navigation/types'; -import { FlowTargetNew } from '../../../graphql/types'; +import { FlowTargetSourceDest } from '../../../graphql/types'; import { networkModel } from '../../../store'; import { ESTermQuery } from '../../../../common/typed_json'; import { NarrowDateRange } from '../../../components/ml/types'; @@ -25,7 +25,7 @@ export type DnsQueryTabBodyProps = QueryTabBodyProps & GlobalTimeArgs; export type IPsQueryTabBodyProps = QueryTabBodyProps & GlobalTimeArgs & { indexPattern: StaticIndexPattern; - flowTarget: FlowTargetNew; + flowTarget: FlowTargetSourceDest; }; export type AnomaliesQueryTabBodyProps = QueryTabBodyProps & diff --git a/x-pack/legacy/plugins/siem/public/store/network/actions.ts b/x-pack/legacy/plugins/siem/public/store/network/actions.ts index 7408037b9f835..c2c71a4643e6a 100644 --- a/x-pack/legacy/plugins/siem/public/store/network/actions.ts +++ b/x-pack/legacy/plugins/siem/public/store/network/actions.ts @@ -7,8 +7,6 @@ import actionCreatorFactory from 'typescript-fsa'; import { - DomainsSortField, - FlowDirection, FlowTarget, NetworkDnsSortField, NetworkTopNFlowSortField, @@ -17,59 +15,61 @@ import { } from '../../graphql/types'; import { KueryFilterQuery, networkModel, SerializedFilterQuery } from '../model'; -import { IpDetailsTableType, NetworkTableType, NetworkType } from './model'; - const actionCreator = actionCreatorFactory('x-pack/siem/local/network'); export const updateNetworkPageTableActivePage = actionCreator<{ activePage: number; - tableType: NetworkTableType; + tableType: networkModel.NetworkTableType; }>('UPDATE_NETWORK_PAGE_TABLE_ACTIVE_PAGE'); -export const setNetworkTablesActivePageToZero = actionCreator( - 'SET_NETWORK_TABLES_ACTIVE_PAGE_TO_ZERO' -); - export const updateIpDetailsTableActivePage = actionCreator<{ activePage: number; - tableType: IpDetailsTableType; + tableType: networkModel.IpDetailsTableType; }>('UPDATE_NETWORK_DETAILS_TABLE_ACTIVE_PAGE'); +export const setIpDetailsTablesActivePageToZero = actionCreator( + 'SET_IP_DETAILS_TABLES_ACTIVE_PAGE_TO_ZERO' +); + +export const setNetworkTablesActivePageToZero = actionCreator( + 'SET_NETWORK_TABLES_ACTIVE_PAGE_TO_ZERO' +); + export const updateDnsLimit = actionCreator<{ limit: number; - networkType: NetworkType; + networkType: networkModel.NetworkType; }>('UPDATE_DNS_LIMIT'); export const updateDnsSort = actionCreator<{ dnsSortField: NetworkDnsSortField; - networkType: NetworkType; + networkType: networkModel.NetworkType; }>('UPDATE_DNS_SORT'); export const updateIsPtrIncluded = actionCreator<{ isPtrIncluded: boolean; - networkType: NetworkType; + networkType: networkModel.NetworkType; }>('UPDATE_DNS_IS_PTR_INCLUDED'); export const updateTopNFlowLimit = actionCreator<{ limit: number; - networkType: NetworkType; + networkType: networkModel.NetworkType; tableType: networkModel.TopNTableType; }>('UPDATE_TOP_N_FLOW_LIMIT'); export const updateTopNFlowSort = actionCreator<{ topNFlowSort: NetworkTopNFlowSortField; - networkType: NetworkType; - tableType: networkModel.NetworkTableType; + networkType: networkModel.NetworkType; + tableType: networkModel.TopNTableType; }>('UPDATE_TOP_N_FLOW_SORT'); export const setNetworkFilterQueryDraft = actionCreator<{ filterQueryDraft: KueryFilterQuery; - networkType: NetworkType; + networkType: networkModel.NetworkType; }>('SET_NETWORK_FILTER_QUERY_DRAFT'); export const applyNetworkFilterQuery = actionCreator<{ filterQuery: SerializedFilterQuery; - networkType: NetworkType; + networkType: networkModel.NetworkType; }>('APPLY_NETWORK_FILTER_QUERY'); // IP Details Actions @@ -77,19 +77,6 @@ export const updateIpDetailsFlowTarget = actionCreator<{ flowTarget: FlowTarget; }>('UPDATE_IP_DETAILS_TARGET'); -// Domains Table Actions -export const updateDomainsLimit = actionCreator<{ - limit: number; -}>('UPDATE_DOMAINS_LIMIT'); - -export const updateDomainsFlowDirection = actionCreator<{ - flowDirection: FlowDirection; -}>('UPDATE_DOMAINS_DIRECTION'); - -export const updateDomainsSort = actionCreator<{ - domainsSortField: DomainsSortField; -}>('UPDATE_DOMAINS_SORT'); - // TLS Table Actions export const updateTlsSort = actionCreator<{ tlsSortField: TlsSortField; diff --git a/x-pack/legacy/plugins/siem/public/store/network/helpers.test.ts b/x-pack/legacy/plugins/siem/public/store/network/helpers.test.ts index 91b918cb45436..13c98eb800916 100644 --- a/x-pack/legacy/plugins/siem/public/store/network/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/store/network/helpers.test.ts @@ -8,8 +8,6 @@ import { Direction, NetworkTopNFlowFields, NetworkDnsFields, - FlowDirection, - DomainsFields, TlsFields, UsersFields, FlowTarget, @@ -52,12 +50,19 @@ export const mockNetworkState: NetworkModel = { }, details: { queries: { - [IpDetailsTableType.domains]: { - activePage: 8, - flowDirection: FlowDirection.uniDirectional, + [IpDetailsTableType.topNFlowSource]: { + activePage: 7, limit: DEFAULT_TABLE_LIMIT, - domainsSortField: { - field: DomainsFields.bytes, + topNFlowSort: { + field: NetworkTopNFlowFields.bytes_out, + direction: Direction.desc, + }, + }, + [IpDetailsTableType.topNFlowDestination]: { + activePage: 3, + limit: DEFAULT_TABLE_LIMIT, + topNFlowSort: { + field: NetworkTopNFlowFields.bytes_out, direction: Direction.desc, }, }, @@ -88,17 +93,17 @@ describe('Network redux store', () => { describe('#setNetworkQueriesActivePageToZero', () => { test('set activePage to zero for all queries in hosts page ', () => { expect(setNetworkQueriesActivePageToZero(mockNetworkState, NetworkType.page)).toEqual({ - topNFlowSource: { + [NetworkTableType.topNFlowSource]: { activePage: 0, limit: 10, topNFlowSort: { field: 'bytes_out', direction: 'desc' }, }, - topNFlowDestination: { + [NetworkTableType.topNFlowDestination]: { activePage: 0, limit: 10, topNFlowSort: { field: 'bytes_out', direction: 'desc' }, }, - dns: { + [NetworkTableType.dns]: { activePage: 0, limit: 10, dnsSortField: { field: 'uniqueDomains', direction: 'desc' }, @@ -109,14 +114,26 @@ describe('Network redux store', () => { test('set activePage to zero for all queries in host details ', () => { expect(setNetworkQueriesActivePageToZero(mockNetworkState, NetworkType.details)).toEqual({ - domains: { + [IpDetailsTableType.topNFlowSource]: { + activePage: 0, + limit: 10, + topNFlowSort: { field: 'bytes_out', direction: 'desc' }, + }, + [IpDetailsTableType.topNFlowDestination]: { + activePage: 0, + limit: 10, + topNFlowSort: { field: 'bytes_out', direction: 'desc' }, + }, + [IpDetailsTableType.tls]: { + activePage: 0, + limit: 10, + tlsSortField: { field: '_id', direction: 'desc' }, + }, + [IpDetailsTableType.users]: { activePage: 0, - flowDirection: 'uniDirectional', limit: 10, - domainsSortField: { field: 'bytes', direction: 'desc' }, + usersSortField: { field: 'name', direction: 'asc' }, }, - tls: { activePage: 0, limit: 10, tlsSortField: { field: '_id', direction: 'desc' } }, - users: { activePage: 0, limit: 10, usersSortField: { field: 'name', direction: 'asc' } }, }); }); }); diff --git a/x-pack/legacy/plugins/siem/public/store/network/helpers.ts b/x-pack/legacy/plugins/siem/public/store/network/helpers.ts index 2d19ad4f44213..b9876457625fb 100644 --- a/x-pack/legacy/plugins/siem/public/store/network/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/store/network/helpers.ts @@ -34,8 +34,12 @@ export const setNetworkDetailsQueriesActivePageToZero = ( state: NetworkModel ): IpOverviewQueries => ({ ...state.details.queries, - [IpDetailsTableType.domains]: { - ...state.details.queries[IpDetailsTableType.domains], + [IpDetailsTableType.topNFlowSource]: { + ...state.details.queries[IpDetailsTableType.topNFlowSource], + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + }, + [IpDetailsTableType.topNFlowDestination]: { + ...state.details.queries[IpDetailsTableType.topNFlowDestination], activePage: DEFAULT_TABLE_ACTIVE_PAGE, }, [IpDetailsTableType.tls]: { diff --git a/x-pack/legacy/plugins/siem/public/store/network/model.ts b/x-pack/legacy/plugins/siem/public/store/network/model.ts index 8fefb6099da6b..541a2fe1a02e3 100644 --- a/x-pack/legacy/plugins/siem/public/store/network/model.ts +++ b/x-pack/legacy/plugins/siem/public/store/network/model.ts @@ -5,15 +5,13 @@ */ import { - DomainsSortField, - FlowDirection, FlowTarget, NetworkDnsSortField, NetworkTopNFlowSortField, TlsSortField, UsersSortField, } from '../../graphql/types'; -import { KueryFilterQuery, networkModel, SerializedFilterQuery } from '../model'; +import { KueryFilterQuery, SerializedFilterQuery } from '../model'; export enum NetworkType { page = 'page', @@ -27,11 +25,14 @@ export enum NetworkTableType { } export type TopNTableType = - | networkModel.NetworkTableType.topNFlowDestination - | networkModel.NetworkTableType.topNFlowSource; + | NetworkTableType.topNFlowDestination + | NetworkTableType.topNFlowSource + | IpDetailsTableType.topNFlowDestination + | IpDetailsTableType.topNFlowSource; export enum IpDetailsTableType { - domains = 'domains', + topNFlowSource = 'topNFlowSourceIp', + topNFlowDestination = 'topNFlowDestinationIp', tls = 'tls', users = 'users', } @@ -64,10 +65,6 @@ export interface NetworkPageModel { } // IP Details Models -export interface DomainsQuery extends BasicQueryPaginated { - flowDirection: FlowDirection; - domainsSortField: DomainsSortField; -} export interface TlsQuery extends BasicQueryPaginated { tlsSortField: TlsSortField; @@ -78,7 +75,8 @@ export interface UsersQuery extends BasicQueryPaginated { } export interface IpOverviewQueries { - [IpDetailsTableType.domains]: DomainsQuery; + [IpDetailsTableType.topNFlowSource]: TopNFlowQuery; + [IpDetailsTableType.topNFlowDestination]: TopNFlowQuery; [IpDetailsTableType.tls]: TlsQuery; [IpDetailsTableType.users]: UsersQuery; } diff --git a/x-pack/legacy/plugins/siem/public/store/network/reducer.ts b/x-pack/legacy/plugins/siem/public/store/network/reducer.ts index 84f0772ee4ec6..df7d496714530 100644 --- a/x-pack/legacy/plugins/siem/public/store/network/reducer.ts +++ b/x-pack/legacy/plugins/siem/public/store/network/reducer.ts @@ -8,8 +8,6 @@ import { reducerWithInitialState } from 'typescript-fsa-reducers'; import { Direction, - DomainsFields, - FlowDirection, FlowTarget, NetworkDnsFields, NetworkTopNFlowFields, @@ -20,29 +18,27 @@ import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../constants'; import { applyNetworkFilterQuery, + setIpDetailsTablesActivePageToZero, setNetworkFilterQueryDraft, + setNetworkTablesActivePageToZero, updateDnsLimit, updateDnsSort, - updateDomainsFlowDirection, - updateDomainsLimit, - updateTlsLimit, - updateDomainsSort, updateIpDetailsFlowTarget, - updateIsPtrIncluded, updateIpDetailsTableActivePage, + updateIsPtrIncluded, updateNetworkPageTableActivePage, + updateTlsLimit, + updateTlsSort, updateTopNFlowLimit, updateTopNFlowSort, - updateTlsSort, updateUsersLimit, updateUsersSort, - setNetworkTablesActivePageToZero, } from './actions'; import { IpDetailsTableType, NetworkModel, NetworkTableType, NetworkType } from './model'; import { - setNetworkQueriesActivePageToZero, - setNetworkPageQueriesActivePageToZero, setNetworkDetailsQueriesActivePageToZero, + setNetworkPageQueriesActivePageToZero, + setNetworkQueriesActivePageToZero, } from './helpers'; export type NetworkState = NetworkModel; @@ -81,12 +77,19 @@ export const initialNetworkState: NetworkState = { }, details: { queries: { - [IpDetailsTableType.domains]: { + [IpDetailsTableType.topNFlowSource]: { activePage: DEFAULT_TABLE_ACTIVE_PAGE, - flowDirection: FlowDirection.uniDirectional, limit: DEFAULT_TABLE_LIMIT, - domainsSortField: { - field: DomainsFields.bytes, + topNFlowSort: { + field: NetworkTopNFlowFields.bytes_out, + direction: Direction.desc, + }, + }, + [IpDetailsTableType.topNFlowDestination]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + topNFlowSort: { + field: NetworkTopNFlowFields.bytes_out, direction: Direction.desc, }, }, @@ -125,6 +128,13 @@ export const networkReducer = reducerWithInitialState(initialNetworkState) queries: setNetworkDetailsQueriesActivePageToZero(state), }, })) + .case(setIpDetailsTablesActivePageToZero, state => ({ + ...state, + details: { + ...state.details, + queries: setNetworkDetailsQueriesActivePageToZero(state), + }, + })) .case(updateIpDetailsTableActivePage, (state, { activePage, tableType }) => ({ ...state, [NetworkType.details]: { @@ -190,32 +200,84 @@ export const networkReducer = reducerWithInitialState(initialNetworkState) }, }, })) - .case(updateTopNFlowLimit, (state, { limit, networkType, tableType }) => ({ - ...state, - [networkType]: { - ...state[networkType], - queries: { - ...state[networkType].queries, - [tableType]: { - ...state[NetworkType.page].queries[tableType], - limit, + .case(updateTopNFlowLimit, (state, { limit, networkType, tableType }) => { + if ( + networkType === NetworkType.page && + (tableType === NetworkTableType.topNFlowSource || + tableType === NetworkTableType.topNFlowDestination) + ) { + return { + ...state, + [networkType]: { + ...state[networkType], + queries: { + ...state[networkType].queries, + [tableType]: { + ...state[networkType].queries[tableType], + limit, + }, + }, }, - }, - }, - })) - .case(updateTopNFlowSort, (state, { topNFlowSort, networkType, tableType }) => ({ - ...state, - [networkType]: { - ...state[networkType], - queries: { - ...state[networkType].queries, - [tableType]: { - ...state[NetworkType.page].queries[tableType], - topNFlowSort, + }; + } else if ( + tableType === IpDetailsTableType.topNFlowDestination || + tableType === IpDetailsTableType.topNFlowSource + ) { + return { + ...state, + [NetworkType.details]: { + ...state[NetworkType.details], + queries: { + ...state[NetworkType.details].queries, + [tableType]: { + ...state[NetworkType.details].queries[tableType], + limit, + }, + }, }, - }, - }, - })) + }; + } + return state; + }) + .case(updateTopNFlowSort, (state, { topNFlowSort, networkType, tableType }) => { + if ( + networkType === NetworkType.page && + (tableType === NetworkTableType.topNFlowSource || + tableType === NetworkTableType.topNFlowDestination) + ) { + return { + ...state, + [networkType]: { + ...state[networkType], + queries: { + ...state[networkType].queries, + [tableType]: { + ...state[networkType].queries[tableType], + topNFlowSort, + }, + }, + }, + }; + } else if ( + tableType === IpDetailsTableType.topNFlowDestination || + tableType === IpDetailsTableType.topNFlowSource + ) { + return { + ...state, + [NetworkType.details]: { + ...state[NetworkType.details], + queries: { + ...state[NetworkType.details].queries, + [tableType]: { + ...state[NetworkType.details].queries[tableType], + topNFlowSort, + }, + }, + }, + }; + } + return state; + }) .case(setNetworkFilterQueryDraft, (state, { filterQueryDraft, networkType }) => ({ ...state, [networkType]: { @@ -239,19 +301,6 @@ export const networkReducer = reducerWithInitialState(initialNetworkState) flowTarget, }, })) - .case(updateDomainsLimit, (state, { limit }) => ({ - ...state, - [NetworkType.details]: { - ...state[NetworkType.details], - queries: { - ...state[NetworkType.details].queries, - [IpDetailsTableType.domains]: { - ...state[NetworkType.details].queries.domains, - limit, - }, - }, - }, - })) .case(updateTlsLimit, (state, { limit }) => ({ ...state, [NetworkType.details]: { @@ -265,32 +314,6 @@ export const networkReducer = reducerWithInitialState(initialNetworkState) }, }, })) - .case(updateDomainsFlowDirection, (state, { flowDirection }) => ({ - ...state, - [NetworkType.details]: { - ...state[NetworkType.details], - queries: { - ...state[NetworkType.details].queries, - [IpDetailsTableType.domains]: { - ...state[NetworkType.details].queries.domains, - flowDirection, - }, - }, - }, - })) - .case(updateDomainsSort, (state, { domainsSortField }) => ({ - ...state, - [NetworkType.details]: { - ...state[NetworkType.details], - queries: { - ...state[NetworkType.details].queries, - [IpDetailsTableType.domains]: { - ...state[NetworkType.details].queries.domains, - domainsSortField, - }, - }, - }, - })) .case(updateTlsSort, (state, { tlsSortField }) => ({ ...state, [NetworkType.details]: { diff --git a/x-pack/legacy/plugins/siem/public/store/network/selectors.ts b/x-pack/legacy/plugins/siem/public/store/network/selectors.ts index 3a11949b3990b..c2d1fa2988e1d 100644 --- a/x-pack/legacy/plugins/siem/public/store/network/selectors.ts +++ b/x-pack/legacy/plugins/siem/public/store/network/selectors.ts @@ -10,8 +10,8 @@ import { createSelector } from 'reselect'; import { isFromKueryExpressionValid } from '../../lib/keury'; import { State } from '../reducer'; -import { NetworkDetailsModel, NetworkPageModel, NetworkType } from './model'; -import { FlowTargetNew } from '../../graphql/types'; +import { IpDetailsTableType, NetworkDetailsModel, NetworkPageModel, NetworkType } from './model'; +import { FlowTargetSourceDest } from '../../graphql/types'; const selectNetworkPage = (state: State): NetworkPageModel => state.network.page; @@ -31,14 +31,24 @@ export enum NetworkTableType { topNFlowSource = 'topNFlowSource', topNFlowDestination = 'topNFlowDestination', } -export const topNFlowSelector = (flowTarget: FlowTargetNew) => - createSelector( - selectNetworkPage, +export const topNFlowSelector = (flowTarget: FlowTargetSourceDest, networkType: NetworkType) => { + if (networkType === NetworkType.page) { + return createSelector( + selectNetworkPage, + network => + flowTarget === FlowTargetSourceDest.source + ? network.queries[NetworkTableType.topNFlowSource] + : network.queries[NetworkTableType.topNFlowDestination] + ); + } + return createSelector( + selectNetworkDetails, network => - flowTarget === FlowTargetNew.source - ? network.queries[NetworkTableType.topNFlowSource] - : network.queries[NetworkTableType.topNFlowDestination] + flowTarget === FlowTargetSourceDest.source + ? network.queries[IpDetailsTableType.topNFlowSource] + : network.queries[IpDetailsTableType.topNFlowDestination] ); +}; // Filter Query Selectors export const networkFilterQueryAsJson = () => @@ -79,12 +89,6 @@ export const ipDetailsFlowTargetSelector = () => network => network.flowTarget ); -export const domainsSelector = () => - createSelector( - selectNetworkDetails, - network => network.queries.domains - ); - export const tlsSelector = () => createSelector( selectNetworkDetails, diff --git a/x-pack/legacy/plugins/siem/server/graphql/ip_details/resolvers.ts b/x-pack/legacy/plugins/siem/server/graphql/ip_details/resolvers.ts index 102cc4d0ae08c..fb65883de12d8 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/ip_details/resolvers.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/ip_details/resolvers.ts @@ -6,12 +6,7 @@ import { SourceResolvers } from '../../graphql/types'; import { AppResolverOf, ChildResolverOf } from '../../lib/framework'; -import { - DomainsRequestOptions, - IpDetails, - TlsRequestOptions, - UsersRequestOptions, -} from '../../lib/ip_details'; +import { IpDetails, TlsRequestOptions, UsersRequestOptions } from '../../lib/ip_details'; import { createOptions, createOptionsPaginated } from '../../utils/build_query/create_options'; import { QuerySourceResolver } from '../sources/resolvers'; @@ -20,11 +15,6 @@ export type QueryIpOverviewResolver = ChildResolverOf< QuerySourceResolver >; -export type QueryDomainsResolver = ChildResolverOf< - AppResolverOf, - QuerySourceResolver ->; - export type QueryTlsResolver = ChildResolverOf< AppResolverOf, QuerySourceResolver @@ -44,7 +34,6 @@ export const createIpDetailsResolvers = ( ): { Source: { IpOverview: QueryIpOverviewResolver; - Domains: QueryDomainsResolver; Tls: QueryTlsResolver; Users: QueryUsersResolver; }; @@ -54,16 +43,6 @@ export const createIpDetailsResolvers = ( const options = { ...createOptions(source, args, info), ip: args.ip }; return libs.ipDetails.getIpOverview(req, options); }, - async Domains(source, args, { req }, info) { - const options: DomainsRequestOptions = { - ...createOptionsPaginated(source, args, info), - ip: args.ip, - domainsSortField: args.sort, - flowTarget: args.flowTarget, - flowDirection: args.flowDirection, - }; - return libs.ipDetails.getDomains(req, options); - }, async Tls(source, args, { req }, info) { const options: TlsRequestOptions = { ...createOptionsPaginated(source, args, info), diff --git a/x-pack/legacy/plugins/siem/server/graphql/ip_details/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/ip_details/schema.gql.ts index 1566c54c7d56b..3acbed3977d73 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/ip_details/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/ip_details/schema.gql.ts @@ -42,71 +42,6 @@ const ipOverviewSchema = gql` } `; -const domainsSchema = gql` - enum DomainsFields { - domainName - direction - bytes - packets - uniqueIpCount - } - - input DomainsSortField { - field: DomainsFields! - direction: Direction! - } - - type DomainsNetworkField { - bytes: Float - packets: Float - transport: String - direction: [NetworkDirectionEcs!] - } - - type DomainsItem { - uniqueIpCount: Float - domainName: String - firstSeen: Date - lastSeen: Date - } - - type DomainsNode { - _id: String - timestamp: Date - source: DomainsItem - destination: DomainsItem - client: DomainsItem - server: DomainsItem - network: DomainsNetworkField - } - - type DomainsEdges { - node: DomainsNode! - cursor: CursorType! - } - - type DomainsData { - edges: [DomainsEdges!]! - totalCount: Float! - pageInfo: PageInfoPaginated! - inspect: Inspect - } - - extend type Source { - Domains( - filterQuery: String - id: String - ip: String! - pagination: PaginationInputPaginated! - sort: DomainsSortField! - flowDirection: FlowDirection! - flowTarget: FlowTarget! - timerange: TimerangeInput! - defaultIndex: [String!]! - ): DomainsData! - } -`; - const tlsSchema = gql` enum TlsFields { _id @@ -199,4 +134,4 @@ const usersSchema = gql` } `; -export const ipDetailsSchemas = [ipOverviewSchema, domainsSchema, tlsSchema, usersSchema]; +export const ipDetailsSchemas = [ipOverviewSchema, tlsSchema, usersSchema]; diff --git a/x-pack/legacy/plugins/siem/server/graphql/network/resolvers.ts b/x-pack/legacy/plugins/siem/server/graphql/network/resolvers.ts index eb358395bd5b6..23a8d4694ccae 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/network/resolvers.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/network/resolvers.ts @@ -38,6 +38,7 @@ export const createNetworkResolvers = ( ...createOptionsPaginated(source, args, info), flowTarget: args.flowTarget, networkTopNFlowSort: args.sort, + ip: args.ip, }; return libs.network.getNetworkTopNFlow(req, options); }, diff --git a/x-pack/legacy/plugins/siem/server/graphql/network/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/network/schema.gql.ts index 52be207fcd871..acd19b6efc0ed 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/network/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/network/schema.gql.ts @@ -25,7 +25,7 @@ export const networkSchema = gql` type GeoItem { geo: GeoEcsFields - flowTarget: FlowTarget + flowTarget: FlowTargetSourceDest } type AutonomousSystemItem { @@ -122,7 +122,8 @@ export const networkSchema = gql` NetworkTopNFlow( id: String filterQuery: String - flowTarget: FlowTargetNew! + ip: String + flowTarget: FlowTargetSourceDest! pagination: PaginationInputPaginated! sort: NetworkTopNFlowSortField! timerange: TimerangeInput! diff --git a/x-pack/legacy/plugins/siem/server/graphql/types.ts b/x-pack/legacy/plugins/siem/server/graphql/types.ts index ae17c9ef067b6..8505d3efc4341 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/types.ts @@ -69,12 +69,6 @@ export interface HostsSortField { direction: Direction; } -export interface DomainsSortField { - field: DomainsFields; - - direction: Direction; -} - export interface TlsSortField { field: TlsFields; @@ -247,17 +241,8 @@ export enum HostsFields { lastSeen = 'lastSeen', } -export enum DomainsFields { - domainName = 'domainName', - direction = 'direction', - bytes = 'bytes', - packets = 'packets', - uniqueIpCount = 'uniqueIpCount', -} - -export enum FlowDirection { - uniDirectional = 'uniDirectional', - biDirectional = 'biDirectional', +export enum TlsFields { + _id = '_id', } export enum FlowTarget { @@ -267,27 +252,12 @@ export enum FlowTarget { source = 'source', } -export enum NetworkDirectionEcs { - inbound = 'inbound', - outbound = 'outbound', - internal = 'internal', - external = 'external', - incoming = 'incoming', - outgoing = 'outgoing', - listening = 'listening', - unknown = 'unknown', -} - -export enum TlsFields { - _id = '_id', -} - export enum UsersFields { name = 'name', count = 'count', } -export enum FlowTargetNew { +export enum FlowTargetSourceDest { destination = 'destination', source = 'source', } @@ -315,6 +285,22 @@ export enum SortFieldTimeline { created = 'created', } +export enum NetworkDirectionEcs { + inbound = 'inbound', + outbound = 'outbound', + internal = 'internal', + external = 'external', + incoming = 'incoming', + outgoing = 'outgoing', + listening = 'listening', + unknown = 'unknown', +} + +export enum FlowDirection { + uniDirectional = 'uniDirectional', + biDirectional = 'biDirectional', +} + export type ToStringArray = string[] | string; export type Date = string; @@ -433,8 +419,6 @@ export interface Source { IpOverview?: Maybe; - Domains: DomainsData; - Tls: TlsData; Users: UsersData; @@ -1330,58 +1314,6 @@ export interface AutonomousSystemOrganization { name?: Maybe; } -export interface DomainsData { - edges: DomainsEdges[]; - - totalCount: number; - - pageInfo: PageInfoPaginated; - - inspect?: Maybe; -} - -export interface DomainsEdges { - node: DomainsNode; - - cursor: CursorType; -} - -export interface DomainsNode { - _id?: Maybe; - - timestamp?: Maybe; - - source?: Maybe; - - destination?: Maybe; - - client?: Maybe; - - server?: Maybe; - - network?: Maybe; -} - -export interface DomainsItem { - uniqueIpCount?: Maybe; - - domainName?: Maybe; - - firstSeen?: Maybe; - - lastSeen?: Maybe; -} - -export interface DomainsNetworkField { - bytes?: Maybe; - - packets?: Maybe; - - transport?: Maybe; - - direction?: Maybe; -} - export interface TlsData { edges: TlsEdges[]; @@ -1575,7 +1507,7 @@ export interface AutonomousSystemItem { export interface GeoItem { geo?: Maybe; - flowTarget?: Maybe; + flowTarget?: Maybe; } export interface TopNFlowItemDestination { @@ -2070,25 +2002,6 @@ export interface IpOverviewSourceArgs { defaultIndex: string[]; } -export interface DomainsSourceArgs { - filterQuery?: Maybe; - - id?: Maybe; - - ip: string; - - pagination: PaginationInputPaginated; - - sort: DomainsSortField; - - flowDirection: FlowDirection; - - flowTarget: FlowTarget; - - timerange: TimerangeInput; - - defaultIndex: string[]; -} export interface TlsSourceArgs { filterQuery?: Maybe; @@ -2155,7 +2068,9 @@ export interface NetworkTopNFlowSourceArgs { filterQuery?: Maybe; - flowTarget: FlowTargetNew; + ip?: Maybe; + + flowTarget: FlowTargetSourceDest; pagination: PaginationInputPaginated; @@ -2618,8 +2533,6 @@ export namespace SourceResolvers { IpOverview?: IpOverviewResolver, TypeParent, TContext>; - Domains?: DomainsResolver; - Tls?: TlsResolver; Users?: UsersResolver; @@ -2796,32 +2709,6 @@ export namespace SourceResolvers { defaultIndex: string[]; } - export type DomainsResolver = Resolver< - R, - Parent, - TContext, - DomainsArgs - >; - export interface DomainsArgs { - filterQuery?: Maybe; - - id?: Maybe; - - ip: string; - - pagination: PaginationInputPaginated; - - sort: DomainsSortField; - - flowDirection: FlowDirection; - - flowTarget: FlowTarget; - - timerange: TimerangeInput; - - defaultIndex: string[]; - } - export type TlsResolver = Resolver< R, Parent, @@ -2925,7 +2812,9 @@ export namespace SourceResolvers { filterQuery?: Maybe; - flowTarget: FlowTargetNew; + ip?: Maybe; + + flowTarget: FlowTargetSourceDest; pagination: PaginationInputPaginated; @@ -5892,178 +5781,6 @@ export namespace AutonomousSystemOrganizationResolvers { > = Resolver; } -export namespace DomainsDataResolvers { - export interface Resolvers { - edges?: EdgesResolver; - - totalCount?: TotalCountResolver; - - pageInfo?: PageInfoResolver; - - inspect?: InspectResolver, TypeParent, TContext>; - } - - export type EdgesResolver< - R = DomainsEdges[], - Parent = DomainsData, - TContext = SiemContext - > = Resolver; - export type TotalCountResolver< - R = number, - Parent = DomainsData, - TContext = SiemContext - > = Resolver; - export type PageInfoResolver< - R = PageInfoPaginated, - Parent = DomainsData, - TContext = SiemContext - > = Resolver; - export type InspectResolver< - R = Maybe, - Parent = DomainsData, - TContext = SiemContext - > = Resolver; -} - -export namespace DomainsEdgesResolvers { - export interface Resolvers { - node?: NodeResolver; - - cursor?: CursorResolver; - } - - export type NodeResolver< - R = DomainsNode, - Parent = DomainsEdges, - TContext = SiemContext - > = Resolver; - export type CursorResolver< - R = CursorType, - Parent = DomainsEdges, - TContext = SiemContext - > = Resolver; -} - -export namespace DomainsNodeResolvers { - export interface Resolvers { - _id?: _IdResolver, TypeParent, TContext>; - - timestamp?: TimestampResolver, TypeParent, TContext>; - - source?: SourceResolver, TypeParent, TContext>; - - destination?: DestinationResolver, TypeParent, TContext>; - - client?: ClientResolver, TypeParent, TContext>; - - server?: ServerResolver, TypeParent, TContext>; - - network?: NetworkResolver, TypeParent, TContext>; - } - - export type _IdResolver< - R = Maybe, - Parent = DomainsNode, - TContext = SiemContext - > = Resolver; - export type TimestampResolver< - R = Maybe, - Parent = DomainsNode, - TContext = SiemContext - > = Resolver; - export type SourceResolver< - R = Maybe, - Parent = DomainsNode, - TContext = SiemContext - > = Resolver; - export type DestinationResolver< - R = Maybe, - Parent = DomainsNode, - TContext = SiemContext - > = Resolver; - export type ClientResolver< - R = Maybe, - Parent = DomainsNode, - TContext = SiemContext - > = Resolver; - export type ServerResolver< - R = Maybe, - Parent = DomainsNode, - TContext = SiemContext - > = Resolver; - export type NetworkResolver< - R = Maybe, - Parent = DomainsNode, - TContext = SiemContext - > = Resolver; -} - -export namespace DomainsItemResolvers { - export interface Resolvers { - uniqueIpCount?: UniqueIpCountResolver, TypeParent, TContext>; - - domainName?: DomainNameResolver, TypeParent, TContext>; - - firstSeen?: FirstSeenResolver, TypeParent, TContext>; - - lastSeen?: LastSeenResolver, TypeParent, TContext>; - } - - export type UniqueIpCountResolver< - R = Maybe, - Parent = DomainsItem, - TContext = SiemContext - > = Resolver; - export type DomainNameResolver< - R = Maybe, - Parent = DomainsItem, - TContext = SiemContext - > = Resolver; - export type FirstSeenResolver< - R = Maybe, - Parent = DomainsItem, - TContext = SiemContext - > = Resolver; - export type LastSeenResolver< - R = Maybe, - Parent = DomainsItem, - TContext = SiemContext - > = Resolver; -} - -export namespace DomainsNetworkFieldResolvers { - export interface Resolvers { - bytes?: BytesResolver, TypeParent, TContext>; - - packets?: PacketsResolver, TypeParent, TContext>; - - transport?: TransportResolver, TypeParent, TContext>; - - direction?: DirectionResolver, TypeParent, TContext>; - } - - export type BytesResolver< - R = Maybe, - Parent = DomainsNetworkField, - TContext = SiemContext - > = Resolver; - export type PacketsResolver< - R = Maybe, - Parent = DomainsNetworkField, - TContext = SiemContext - > = Resolver; - export type TransportResolver< - R = Maybe, - Parent = DomainsNetworkField, - TContext = SiemContext - > = Resolver; - export type DirectionResolver< - R = Maybe, - Parent = DomainsNetworkField, - TContext = SiemContext - > = Resolver; -} - export namespace TlsDataResolvers { export interface Resolvers { edges?: EdgesResolver; @@ -6743,7 +6460,7 @@ export namespace GeoItemResolvers { export interface Resolvers { geo?: GeoResolver, TypeParent, TContext>; - flowTarget?: FlowTargetResolver, TypeParent, TContext>; + flowTarget?: FlowTargetResolver, TypeParent, TContext>; } export type GeoResolver< @@ -6752,7 +6469,7 @@ export namespace GeoItemResolvers { TContext = SiemContext > = Resolver; export type FlowTargetResolver< - R = Maybe, + R = Maybe, Parent = GeoItem, TContext = SiemContext > = Resolver; @@ -8193,11 +7910,6 @@ export type IResolvers = { Overview?: OverviewResolvers.Resolvers; AutonomousSystem?: AutonomousSystemResolvers.Resolvers; AutonomousSystemOrganization?: AutonomousSystemOrganizationResolvers.Resolvers; - DomainsData?: DomainsDataResolvers.Resolvers; - DomainsEdges?: DomainsEdgesResolvers.Resolvers; - DomainsNode?: DomainsNodeResolvers.Resolvers; - DomainsItem?: DomainsItemResolvers.Resolvers; - DomainsNetworkField?: DomainsNetworkFieldResolvers.Resolvers; TlsData?: TlsDataResolvers.Resolvers; TlsEdges?: TlsEdgesResolvers.Resolvers; TlsNode?: TlsNodeResolvers.Resolvers; diff --git a/x-pack/legacy/plugins/siem/server/kibana.index.ts b/x-pack/legacy/plugins/siem/server/kibana.index.ts index ea29b7cdeef81..0e69cfda7af1a 100644 --- a/x-pack/legacy/plugins/siem/server/kibana.index.ts +++ b/x-pack/legacy/plugins/siem/server/kibana.index.ts @@ -17,6 +17,10 @@ import { } from './saved_objects'; import { createSignalsRoute } from './lib/detection_engine/routes/create_signals_route'; +import { readSignalsRoute } from './lib/detection_engine/routes/read_signals_route'; +import { findSignalsRoute } from './lib/detection_engine/routes/find_signals_route'; +import { deleteSignalsRoute } from './lib/detection_engine/routes/delete_signals_route'; +import { updateSignalsRoute } from './lib/detection_engine/routes/updated_signals_route'; const APP_ID = 'siem'; @@ -46,6 +50,10 @@ export const initServerWithKibana = (kbnServer: Server) => { 'Detected feature flags for actions and alerting and enabling signals API endpoints' ); createSignalsRoute(kbnServer); + readSignalsRoute(kbnServer); + updateSignalsRoute(kbnServer); + deleteSignalsRoute(kbnServer); + findSignalsRoute(kbnServer); } logger.info('Plugin done initializing'); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/README.md b/x-pack/legacy/plugins/siem/server/lib/detection_engine/README.md index b90cc93406999..5ac2a7a2d3060 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/README.md +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/README.md @@ -17,6 +17,7 @@ export ELASTICSEARCH_URL=https://${ip}:9200 export KIBANA_URL=http://localhost:5601 export SIGNALS_INDEX=.siem-signals-${your user id} export TASK_MANAGER_INDEX=.kibana-task-manager-${your user id} +export KIBANA_INDEX=.kibana-${your user id} # This is for the kbn-action and kbn-alert tool export KBN_URLBASE=http://${user}:${password}@localhost:5601 @@ -69,18 +70,19 @@ server log [11:39:05.561] [info][siem] Detected feature flags for actions a Open a terminal and go into the scripts folder `cd kibana/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts` and run: ``` -./delete_signal_index.sh -./put_signal_index.sh +./hard_reset.sh ./post_signal.sh ``` which will: +* Delete any existing actions you have +* Delete any existing alerts you have +* Delete any existing alert tasks you have * Delete any existing signal mapping you might have had. * Add the latest signal index and its mappings * Posts a sample signal which checks for root or admin every 5 minutes - Now you can run ```sh diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signal.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signal.ts index 6ebdecfeeba83..8685b4f082fa5 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signal.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signal.ts @@ -82,6 +82,7 @@ export const createSignal = async ({ id: actionResults.id, params: { message: 'SIEM Alert Fired', + level: 'info', }, }, ], diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/delete_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/delete_signals.ts new file mode 100644 index 0000000000000..dad9147d9eb68 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/delete_signals.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertAction } from '../../../../../alerting/server/types'; +import { ActionsClient } from '../../../../../actions/server/actions_client'; +import { AlertsClient } from '../../../../../alerting/server/alerts_client'; + +export interface DeleteSignalParams { + alertsClient: AlertsClient; + actionsClient: ActionsClient; + id: string; +} + +export const deleteAllSignalActions = async ( + actionsClient: ActionsClient, + actions: AlertAction[] +): Promise => { + try { + await Promise.all(actions.map(async ({ id }) => actionsClient.delete({ id }))); + return null; + } catch (error) { + return error; + } +}; + +export const deleteSignals = async ({ alertsClient, actionsClient, id }: DeleteSignalParams) => { + const alert = await alertsClient.get({ id }); + + // TODO: Remove this as cast as soon as signal.actions TypeScript bug is fixed + // where it is trying to return AlertAction[] or RawAlertAction[] + const actions = (alert.actions as (AlertAction[] | undefined)) || []; + + const actionsErrors = await deleteAllSignalActions(actionsClient, actions); + const deletedAlert = await alertsClient.delete({ id }); + if (actionsErrors != null) { + throw actionsErrors; + } else { + return deletedAlert; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.ts new file mode 100644 index 0000000000000..0dd784d83f31f --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.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 { SIGNALS_ID } from '../../../../common/constants'; +import { AlertsClient } from '../../../../../alerting/server/alerts_client'; + +export interface GetSignalParams { + alertsClient: AlertsClient; + perPage?: number; + page?: number; + sortField?: string; + fields?: string[]; +} + +// TODO: Change this from a search to a filter once this ticket is solved: +// https://github.com/elastic/kibana/projects/26#card-27462236 +export const findSignals = async ({ alertsClient, perPage, page, fields }: GetSignalParams) => { + return alertsClient.find({ + options: { + fields, + page, + perPage, + searchFields: ['alertTypeId'], + search: SIGNALS_ID, + }, + }); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/read_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/read_signals.ts new file mode 100644 index 0000000000000..15b4cd57e3aa2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/read_signals.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 { AlertsClient } from '../../../../../alerting/server/alerts_client'; + +export interface ReadSignalParams { + alertsClient: AlertsClient; + id: string; +} + +// TODO: Change this from a search to a filter once this ticket is solved: +// https://github.com/elastic/kibana/projects/26#card-27462236 +export const readSignals = async ({ alertsClient, id }: ReadSignalParams) => { + return alertsClient.get({ id }); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts index adc62917b961f..54e7799da982c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts @@ -28,10 +28,10 @@ export const signalsAlertType = ({ logger }: { logger: Logger }): AlertType => { params: schema.object({ description: schema.string(), from: schema.string(), - filter: schema.maybe(schema.object({}, { allowUnknowns: true })), + filter: schema.nullable(schema.object({}, { allowUnknowns: true })), id: schema.number(), index: schema.arrayOf(schema.string()), - kql: schema.maybe(schema.string({ defaultValue: undefined })), + kql: schema.nullable(schema.string()), maxSignals: schema.number({ defaultValue: 100 }), name: schema.string(), severity: schema.number(), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/update_signals.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/update_signals.test.ts new file mode 100644 index 0000000000000..e3e00e5cea5ce --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/update_signals.test.ts @@ -0,0 +1,52 @@ +/* + * 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 { calculateInterval, calculateKqlAndFilter } from './update_signals'; + +describe('update_signals', () => { + describe('#calculateInterval', () => { + test('given a undefined interval, it returns the signalInterval ', () => { + const interval = calculateInterval(undefined, '10m'); + expect(interval).toEqual('10m'); + }); + + test('given a undefined signalInterval, it returns a undefined interval ', () => { + const interval = calculateInterval('10m', undefined); + expect(interval).toEqual('10m'); + }); + + test('given both an undefined signalInterval and a undefined interval, it returns 5m', () => { + const interval = calculateInterval(undefined, undefined); + expect(interval).toEqual('5m'); + }); + }); + + describe('#calculateKqlAndFilter', () => { + test('given a undefined kql filter it returns a null kql', () => { + const kqlFilter = calculateKqlAndFilter(undefined, {}); + expect(kqlFilter).toEqual({ + filter: {}, + kql: null, + }); + }); + + test('given a undefined filter it returns a null filter', () => { + const kqlFilter = calculateKqlAndFilter('some kql string', undefined); + expect(kqlFilter).toEqual({ + filter: null, + kql: 'some kql string', + }); + }); + + test('given both a undefined filter and undefined kql it returns both as undefined', () => { + const kqlFilter = calculateKqlAndFilter(undefined, undefined); + expect(kqlFilter).toEqual({ + filter: undefined, + kql: undefined, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/update_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/update_signals.ts new file mode 100644 index 0000000000000..7c307695dd2a5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/update_signals.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { defaults } from 'lodash/fp'; +import { AlertAction } from '../../../../../alerting/server/types'; +import { AlertsClient } from '../../../../../alerting/server/alerts_client'; +import { ActionsClient } from '../../../../../actions/server/actions_client'; +import { readSignals } from './read_signals'; + +export interface SignalParams { + alertsClient: AlertsClient; + actionsClient: ActionsClient; + description?: string; + from?: string; + id: string; + index?: string[]; + interval?: string; + enabled?: boolean; + filter?: Record | undefined; + kql?: string | undefined; + maxSignals?: string; + name?: string; + severity?: number; + type?: string; // TODO: Replace this type with a static enum type + to?: string; + references?: string[]; +} + +export const calculateInterval = ( + interval: string | undefined, + signalInterval: string | undefined +): string => { + if (interval != null) { + return interval; + } else if (signalInterval != null) { + return signalInterval; + } else { + return '5m'; + } +}; + +export const calculateKqlAndFilter = ( + kql: string | undefined, + filter: {} | undefined +): { kql: string | null | undefined; filter: {} | null | undefined } => { + if (filter != null) { + return { kql: null, filter }; + } else if (kql != null) { + return { kql, filter: null }; + } else { + return { kql: undefined, filter: undefined }; + } +}; + +export const updateSignal = async ({ + alertsClient, + actionsClient, // TODO: Use this whenever we add feature support for different action types + description, + enabled, + filter, + from, + id, + index, + interval, + kql, + name, + severity, + to, + type, + references, +}: SignalParams) => { + // TODO: Error handling and abstraction. Right now if this is an error then what happens is we get the error of + // "message": "Saved object [alert/{id}] not found" + const signal = await readSignals({ alertsClient, id }); + + // TODO: Remove this as cast as soon as signal.actions TypeScript bug is fixed + // where it is trying to return AlertAction[] or RawAlertAction[] + const actions = (signal.actions as AlertAction[] | undefined) || []; + + const alertTypeParams = signal.alertTypeParams || {}; + + const { kql: nextKql, filter: nextFilter } = calculateKqlAndFilter(kql, filter); + + const nextAlertTypeParams = defaults( + { + ...alertTypeParams, + }, + { + description, + filter: nextFilter, + from, + index, + kql: nextKql, + name, + severity, + to, + type, + references, + } + ); + + if (signal.enabled && !enabled) { + await alertsClient.disable({ id }); + } else if (!signal.enabled && enabled) { + await alertsClient.enable({ id }); + } + + return alertsClient.update({ + id, + data: { + interval: calculateInterval(interval, signal.interval), + actions, + alertTypeParams: nextAlertTypeParams, + }, + }); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals_route.ts index 19070243520b8..5ba8e9e363209 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals_route.ts @@ -47,7 +47,7 @@ export const createSignalsRoute = (server: Hapi.Server) => { index: Joi.array().required(), interval: Joi.string().default('5m'), kql: Joi.string(), - max_signals: Joi.array().default([]), + max_signals: Joi.number().default(100), name: Joi.string().required(), severity: Joi.number().required(), to: Joi.string().required(), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/delete_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/delete_signals_route.ts new file mode 100644 index 0000000000000..559077c862c1e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/delete_signals_route.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 Hapi from 'hapi'; +import { isFunction } from 'lodash/fp'; + +import { deleteSignals } from '../alerts/delete_signals'; + +export const deleteSignalsRoute = (server: Hapi.Server) => { + server.route({ + method: 'DELETE', + path: '/api/siem/signals/{id}', + options: { + tags: ['access:signals-all'], + validate: { + options: { + abortEarly: false, + }, + }, + }, + async handler(request: Hapi.Request, headers) { + const { id } = request.params; + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const actionsClient = isFunction(request.getActionsClient) + ? request.getActionsClient() + : null; + if (alertsClient == null || actionsClient == null) { + return headers.response().code(404); + } + return deleteSignals({ + actionsClient, + alertsClient, + id, + }); + }, + }); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.ts new file mode 100644 index 0000000000000..f774f8d76797d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.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 Hapi from 'hapi'; +import Joi from 'joi'; +import { isFunction } from 'lodash/fp'; +import { findSignals } from '../alerts/find_signals'; + +interface FindSignalsRequest extends Omit { + query: { + per_page: number; + page: number; + search?: string; + sort_field?: string; + fields?: string[]; + }; +} + +export const findSignalsRoute = (server: Hapi.Server) => { + server.route({ + method: 'GET', + path: '/api/siem/signals/_find', + options: { + tags: ['access:signals-all'], + validate: { + options: { + abortEarly: false, + }, + query: Joi.object() + .keys({ + per_page: Joi.number() + .min(0) + .default(20), + page: Joi.number() + .min(1) + .default(1), + sort_field: Joi.string(), + fields: Joi.array() + .items(Joi.string()) + .single(), + }) + .default(), + }, + }, + async handler(request: FindSignalsRequest, headers) { + const { query } = request; + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + if (alertsClient == null) { + return headers.response().code(404); + } + + return findSignals({ + alertsClient, + perPage: query.per_page, + page: query.page, + sortField: query.sort_field, + }); + }, + }); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/read_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/read_signals_route.ts new file mode 100644 index 0000000000000..0d4f9ac0ef1fc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/read_signals_route.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 Hapi from 'hapi'; +import { isFunction } from 'lodash/fp'; + +import { readSignals } from '../alerts/read_signals'; + +export const readSignalsRoute = (server: Hapi.Server) => { + server.route({ + method: 'GET', + path: '/api/siem/signals/{id}', + options: { + tags: ['access:signals-all'], + validate: { + options: { + abortEarly: false, + }, + }, + }, + async handler(request: Hapi.Request, headers) { + const { id } = request.params; + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + if (alertsClient == null) { + return headers.response().code(404); + } + return readSignals({ + alertsClient, + id, + }); + }, + }); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/updated_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/updated_signals_route.ts new file mode 100644 index 0000000000000..be567695f730d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/updated_signals_route.ts @@ -0,0 +1,114 @@ +/* + * 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 Hapi from 'hapi'; +import Joi from 'joi'; +import { isFunction } from 'lodash/fp'; +import { updateSignal } from '../alerts/update_signals'; + +interface SignalsRequest extends Hapi.Request { + payload: { + description: string; + enabled: boolean; + filter: Record | undefined; + from: string; + id: string; + index: string[]; + interval: string; + kql: string | undefined; + max_signals: string; + name: string; + severity: number; + type: string; + to: string; + references: string[]; + }; +} + +export const updateSignalsRoute = (server: Hapi.Server) => { + server.route({ + method: 'PUT', + path: '/api/siem/signals/{id?}', + options: { + tags: ['access:signals-all'], + validate: { + options: { + abortEarly: false, + }, + params: { + id: Joi.when(Joi.ref('$payload.id'), { + is: Joi.exist(), + then: Joi.string().optional(), + otherwise: Joi.string().required(), + }), + }, + payload: Joi.object({ + description: Joi.string(), + enabled: Joi.boolean(), + filter: Joi.object(), + from: Joi.string(), + id: Joi.string(), + index: Joi.array(), + interval: Joi.string(), + kql: Joi.string(), + max_signals: Joi.number().default(100), + name: Joi.string(), + severity: Joi.number(), + to: Joi.string(), + type: Joi.string(), // TODO: Restrict this to only be kql or filter for the moment + references: Joi.array().default([]), + }).nand('filter', 'kql'), + }, + }, + async handler(request: SignalsRequest, headers) { + const { + description, + enabled, + filter, + kql, + from, + id, + index, + interval, + // eslint-disable-next-line @typescript-eslint/camelcase + max_signals: maxSignals, + name, + severity, + to, + type, + references, + } = request.payload; + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + + const actionsClient = isFunction(request.getActionsClient) + ? request.getActionsClient() + : null; + + if (!alertsClient || !actionsClient) { + return headers.response().code(404); + } + + return updateSignal({ + alertsClient, + actionsClient, + description, + enabled, + filter, + from, + id: request.params.id ? request.params.id : id, + index, + interval, + kql, + maxSignals, + name, + severity, + to, + type, + references, + }); + }, + }); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/README.md b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/README.md index 0258f5455ca87..b3ab0011e1f8f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/README.md +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/README.md @@ -11,6 +11,7 @@ export ELASTICSEARCH_URL=https://${ip}:9200 export KIBANA_URL=http://localhost:5601 export SIGNALS_INDEX=.siem-signals-${your user id} export TASK_MANAGER_INDEX=.kibana-task-manager-${your user id} +export KIBANA_INDEX=.kibana-${your user id} # This is for the kbn-action and kbn-alert tool export KBN_URLBASE=http://${user}:${password}@localhost:5601 diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/check_env_variables.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/check_env_variables.sh new file mode 100755 index 0000000000000..c534b33d28413 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/check_env_variables.sh @@ -0,0 +1,46 @@ +#!/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. +# + +# Add this to the start of any scripts to detect if env variables are set + +set -e + +if [ -z "${ELASTICSEARCH_USERNAME}" ]; then + echo "Set ELASTICSEARCH_USERNAME in your enviornment" + exit 1 +fi + +if [ -z "${ELASTICSEARCH_PASSWORD}" ]; then + echo "Set ELASTICSEARCH_PASSWORD in your enviornment" + exit 1 +fi + +if [ -z "${ELASTICSEARCH_URL}" ]; then + echo "Set ELASTICSEARCH_URL in your enviornment" + exit 1 +fi + +if [ -z "${KIBANA_URL}" ]; then + echo "Set KIBANA_URL in your enviornment" + exit 1 +fi + +if [ -z "${SIGNALS_INDEX}" ]; then + echo "Set SIGNALS_INDEX in your enviornment" + exit 1 +fi + +if [ -z "${TASK_MANAGER_INDEX}" ]; then + echo "Set TASK_MANAGER_INDEX in your enviornment" + exit 1 +fi + +if [ -z "${KIBANA_INDEX}" ]; then + echo "Set KIBANA_INDEX in your enviornment" + exit 1 +fi diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_actions.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_actions.sh new file mode 100755 index 0000000000000..e9818980c7506 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_actions.sh @@ -0,0 +1,34 @@ +#!/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 + +# Example: ./delete_all_actions.sh +# https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html +curl -s -k \ + -H "Content-Type: application/json" \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${ELASTICSEARCH_URL}/${KIBANA_INDEX}*/_delete_by_query \ + --data '{ + "query": { + "exists": { "field": "action" } + } + }' \ + | jq . + +curl -s -k \ + -H "Content-Type: application/json" \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${ELASTICSEARCH_URL}/${KIBANA_INDEX}*/_delete_by_query \ + --data '{ + "query": { + "exists": { "field": "action_task_params" } + } + }' \ + | jq . diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_alert_tasks.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_alert_tasks.sh new file mode 100755 index 0000000000000..cd285ac180219 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_alert_tasks.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 + +# Example: ./delete_all_alert_tasks.sh +# https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html +curl -s -k \ + -H "Content-Type: application/json" \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${ELASTICSEARCH_URL}/${TASK_MANAGER_INDEX}*/_delete_by_query \ + --data '{ + "query": { + "term" : { "task.scope" : "alerting" } + } + }' \ + | jq . diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_alerts.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_alerts.sh new file mode 100755 index 0000000000000..f4038d4afe0cb --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_alerts.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 + +# Example: ./delete_all_alerts.sh +# https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html +curl -s -k \ + -H "Content-Type: application/json" \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${ELASTICSEARCH_URL}/${KIBANA_INDEX}*/_delete_by_query \ + --data '{ + "query": { + "exists": { "field": "alert" } + } + }' \ + | jq . diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_signal.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_signal.sh new file mode 100755 index 0000000000000..c393665315e25 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_signal.sh @@ -0,0 +1,16 @@ +#!/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 + +# Example: ./delete_signal.sh ${id} +curl -s -k \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X DELETE ${KIBANA_URL}/api/siem/signals/$1 | jq . diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_signal_index.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_signal_index.sh index edb88d8b9338c..8d5deec1ba3a1 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_signal_index.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_signal_index.sh @@ -1,5 +1,14 @@ #!/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 + # Example: ./delete_signal_index.sh # https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-delete-index.html curl -s -k \ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_saved_object.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_saved_object.sh index 3b9b8686df9a6..2b26c939a924c 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_saved_object.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_saved_object.sh @@ -1,5 +1,14 @@ #!/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 of alert if no argument is specified TYPE=${1:-alert} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_signals.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_signals.sh new file mode 100755 index 0000000000000..f851bda0c12c9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_signals.sh @@ -0,0 +1,15 @@ +#!/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 + +# Example: ./find_signals.sh +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET ${KIBANA_URL}/api/siem/signals/_find | jq . diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_action_instances.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_action_instances.sh index e04bdba4f1073..e2177bb750057 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_action_instances.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_action_instances.sh @@ -1,5 +1,14 @@ #!/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 + # Example: ./get_action_instances.sh # https://github.com/elastic/kibana/blob/master/x-pack/legacy/plugins/actions/README.md#get-apiaction_find-find-actions curl -s -k \ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_action_types.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_action_types.sh index 47a571f47bf68..7937f2f99a37f 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_action_types.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_action_types.sh @@ -1,5 +1,14 @@ #!/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 + # Example: ./get_action_types.sh # https://github.com/elastic/kibana/blob/master/x-pack/legacy/plugins/actions/README.md curl -s -k \ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_instances.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_instances.sh index a3c03d56d6506..3abc8c9adee62 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_instances.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_instances.sh @@ -1,5 +1,14 @@ #!/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 + # Example: ./get_alert_instances.sh # https://github.com/elastic/kibana/blob/master/x-pack/legacy/plugins/alerting/README.md#get-apialert_find-find-alerts curl -s -k \ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_tasks.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_tasks.sh index 210fc2d532b7d..8a94c254ab656 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_tasks.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_tasks.sh @@ -1,5 +1,14 @@ #!/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 + # Example: ./get_alert_tasks.sh # https://www.elastic.co/guide/en/elasticsearch/reference/current/tasks.html curl -s -k \ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_types.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_types.sh index e70bbb9e882c3..7f7361a6252bc 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_types.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_alert_types.sh @@ -1,5 +1,14 @@ #!/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 + # Example: ./get_alert_types.sh # https://github.com/elastic/kibana/blob/master/x-pack/legacy/plugins/alerting/README.md#get-apialerttypes-list-alert-types curl -s -k \ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_saved_objects.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_saved_objects.sh index 71725ed308a85..4829beba86743 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_saved_objects.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_saved_objects.sh @@ -1,6 +1,16 @@ #!/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 + # Example: ./get_saved_object.sh +# Example: ./get_saved_objects.sh alert 836dab88-edff-42a5-a219-4aae46fcd385 # https://www.elastic.co/guide/en/kibana/master/saved-objects-api-get.html curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_signal_mapping.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_signal_mapping.sh index d30f66df72eda..8b384fcc76f72 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_signal_mapping.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_signal_mapping.sh @@ -1,5 +1,14 @@ #!/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 + # Example: ./get_signal_mapping.sh # https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-get-mapping.html curl -s -k \ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/hard_reset.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/hard_reset.sh new file mode 100755 index 0000000000000..ee8fa18e1234d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/hard_reset.sh @@ -0,0 +1,16 @@ +#!/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 + +./delete_all_actions.sh +./delete_all_alerts.sh +./delete_all_alert_tasks.sh +./delete_signal_index.sh +./put_signal_index.sh diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_signal.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_signal.sh index 3393084c56c96..961953c71b2dc 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_signal.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_signal.sh @@ -1,5 +1,14 @@ #!/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 SIGNAL=${1:-./signals/root_or_admin_1.json} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/put_signal_index.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/put_signal_index.sh index 54b60c7ccdbe6..1b3b148a99161 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/put_signal_index.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/put_signal_index.sh @@ -1,7 +1,15 @@ #!/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 + # Example: ./put_signal_index.sh -# https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-put-mapping.html curl -s -k \ -H "Content-Type: application/json" \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/read_signal.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/read_signal.sh new file mode 100755 index 0000000000000..7eb07e6e2dedf --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/read_signal.sh @@ -0,0 +1,15 @@ +#!/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 + +# Example: ./read_signal.sh {id} +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET ${KIBANA_URL}/api/siem/signals/$1 | jq . diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/temp_update_1.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/temp_update_1.json new file mode 100644 index 0000000000000..8ca571666ea4e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/temp_update_1.json @@ -0,0 +1,13 @@ +{ + "id": "f47e5de7-0023-4898-b856-cf3874e883ea", + "description": "Only watch winlogbeat users", + "index": ["winlogbeat-*"], + "interval": "9m", + "name": "Just watch other winlogbeat users", + "severity": 500, + "enabled": false, + "type": "filter", + "from": "now-5d", + "to": "now-1d", + "kql": "user.name: something_else" +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_signal.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_signal.sh new file mode 100755 index 0000000000000..0980999156f11 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_signal.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 + +# TODO: Since we only have GUID's at the moment, you have to use ./find_signals.sh and then copy and paste that +# into the temp_update_1.json as your ID in order to run this script. + +# Uses a default if no argument is specified +SIGNAL=${1:-./signals/temp_update_1.json} + +# Example: ./update_signal.sh {id} ./signals/root_or_admin_1.json +curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X PUT ${KIBANA_URL}/api/siem/signals \ + -d @${SIGNAL} \ + | jq . diff --git a/x-pack/legacy/plugins/siem/server/lib/ip_details/elasticsearch_adapter.test.ts b/x-pack/legacy/plugins/siem/server/lib/ip_details/elasticsearch_adapter.test.ts index 09eed2af8a980..6493a3e05bfc9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/ip_details/elasticsearch_adapter.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/ip_details/elasticsearch_adapter.test.ts @@ -5,21 +5,13 @@ */ import { FlowTarget } from '../../graphql/types'; -import { - formatDomainsEdges, - getIpOverviewAgg, - getIpOverviewHostAgg, - getUsersEdges, -} from './elasticsearch_adapter'; +import { getIpOverviewAgg, getIpOverviewHostAgg, getUsersEdges } from './elasticsearch_adapter'; import { formattedDestination, formattedEmptySource, formattedHost, formattedSource, - mockDomainsResponseBuckets, - mockFormattedDestination, - mockFormattedSource, mockFormattedUsersEdges, mockUsersData, responseAggs, @@ -51,18 +43,6 @@ describe('elasticsearch_adapter', () => { }); }); - describe('#getDomains', () => { - test('will return a source correctly', () => { - const source = formatDomainsEdges(mockDomainsResponseBuckets, FlowTarget.source); - expect(source).toEqual(mockFormattedSource); - }); - - test('will return a destination correctly', () => { - const destination = formatDomainsEdges(mockDomainsResponseBuckets, FlowTarget.destination); - expect(destination).toEqual(mockFormattedDestination); - }); - }); - describe('#getUsers', () => { test('will format edges correctly', () => { // @ts-ignore Re-work `DatabaseSearchResponse` types as mock ES Response won't match diff --git a/x-pack/legacy/plugins/siem/server/lib/ip_details/elasticsearch_adapter.ts b/x-pack/legacy/plugins/siem/server/lib/ip_details/elasticsearch_adapter.ts index cb781021b7e61..5443ff62f6227 100644 --- a/x-pack/legacy/plugins/siem/server/lib/ip_details/elasticsearch_adapter.ts +++ b/x-pack/legacy/plugins/siem/server/lib/ip_details/elasticsearch_adapter.ts @@ -8,9 +8,6 @@ import { get, getOr } from 'lodash/fp'; import { AutonomousSystem, - DomainsData, - DomainsEdges, - FlowTarget, GeoEcsFields, HostEcsFields, IpOverviewData, @@ -23,16 +20,9 @@ import { inspectStringifyObject } from '../../utils/build_query'; import { DatabaseSearchResponse, FrameworkAdapter, FrameworkRequest } from '../framework'; import { TermAggregation } from '../types'; import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../common/constants'; -import { - DomainsRequestOptions, - IpOverviewRequestOptions, - TlsRequestOptions, - UsersRequestOptions, -} from './index'; -import { buildDomainsQuery } from './query_domains.dsl'; +import { IpOverviewRequestOptions, TlsRequestOptions, UsersRequestOptions } from './index'; import { buildOverviewQuery } from './query_overview.dsl'; import { - DomainsBuckets, IpDetailsAdapter, IpOverviewHit, OverviewHit, @@ -72,42 +62,6 @@ export class ElasticsearchIpOverviewAdapter implements IpDetailsAdapter { }; } - public async getDomains( - request: FrameworkRequest, - options: DomainsRequestOptions - ): Promise { - if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { - throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); - } - const dsl = buildDomainsQuery(options); - const response = await this.framework.callWithRequest( - request, - 'search', - dsl - ); - - const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; - const totalCount = getOr(0, 'aggregations.domain_count.value', response); - const domainsEdges: DomainsEdges[] = getDomainsEdges(response, options); - const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; - const edges = domainsEdges.splice(cursorStart, querySize - cursorStart); - const inspect = { - dsl: [inspectStringifyObject(dsl)], - response: [inspectStringifyObject(response)], - }; - const showMorePagesIndicator = totalCount > fakeTotalCount; - return { - edges, - inspect, - pageInfo: { - activePage: activePage ? activePage : 0, - fakeTotalCount, - showMorePagesIndicator, - }, - totalCount, - }; - } - public async getTls(request: FrameworkRequest, options: TlsRequestOptions): Promise { if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); @@ -219,40 +173,6 @@ export const getIpOverviewHostAgg = (overviewHostHit: OverviewHostHit | {}) => { }; }; -const getDomainsEdges = ( - response: DatabaseSearchResponse, - options: DomainsRequestOptions -): DomainsEdges[] => { - return formatDomainsEdges( - getOr([], `aggregations.${options.flowTarget}_domains.buckets`, response), - options.flowTarget - ); -}; - -export const formatDomainsEdges = ( - buckets: DomainsBuckets[], - flowTarget: FlowTarget -): DomainsEdges[] => - buckets.map((bucket: DomainsBuckets) => ({ - node: { - _id: bucket.key, - [flowTarget]: { - uniqueIpCount: getOrNumber('uniqueIpCount.value', bucket), - domainName: bucket.key, - lastSeen: get('lastSeen.value_as_string', bucket), - }, - network: { - bytes: getOrNumber('bytes.value', bucket), - packets: getOrNumber('packets.value', bucket), - direction: bucket.direction.buckets.map(bucketDir => bucketDir.key), - }, - }, - cursor: { - value: bucket.key, - tiebreaker: null, - }, - })); - const getTlsEdges = ( response: DatabaseSearchResponse, options: TlsRequestOptions @@ -281,14 +201,6 @@ export const formatTlsEdges = (buckets: TlsBuckets[]): TlsEdges[] => { }); }; -const getOrNumber = (path: string, bucket: DomainsBuckets) => { - const numb = get(path, bucket); - if (numb == null) { - return null; - } - return numb; -}; - export const getUsersEdges = ( response: DatabaseSearchResponse ): UsersEdges[] => diff --git a/x-pack/legacy/plugins/siem/server/lib/ip_details/index.ts b/x-pack/legacy/plugins/siem/server/lib/ip_details/index.ts index d06ce065931a7..4a16664790a05 100644 --- a/x-pack/legacy/plugins/siem/server/lib/ip_details/index.ts +++ b/x-pack/legacy/plugins/siem/server/lib/ip_details/index.ts @@ -5,9 +5,6 @@ */ import { - DomainsData, - DomainsSortField, - FlowDirection, FlowTarget, IpOverviewData, TlsSortField, @@ -25,13 +22,6 @@ export interface IpOverviewRequestOptions extends RequestOptions { ip: string; } -export interface DomainsRequestOptions extends RequestOptionsPaginated { - ip: string; - domainsSortField: DomainsSortField; - flowTarget: FlowTarget; - flowDirection: FlowDirection; -} - export interface TlsRequestOptions extends RequestOptionsPaginated { ip: string; tlsSortField: TlsSortField; @@ -53,13 +43,6 @@ export class IpDetails { return this.adapter.getIpDetails(req, options); } - public async getDomains( - req: FrameworkRequest, - options: DomainsRequestOptions - ): Promise { - return this.adapter.getDomains(req, options); - } - public async getTls(req: FrameworkRequest, options: TlsRequestOptions): Promise { return this.adapter.getTls(req, options); } diff --git a/x-pack/legacy/plugins/siem/server/lib/ip_details/mock.ts b/x-pack/legacy/plugins/siem/server/lib/ip_details/mock.ts index 9466f9d437fa3..1db86e7766fcf 100644 --- a/x-pack/legacy/plugins/siem/server/lib/ip_details/mock.ts +++ b/x-pack/legacy/plugins/siem/server/lib/ip_details/mock.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DomainsEdges, NetworkDirectionEcs, UsersEdges } from '../../graphql/types'; +import { UsersEdges } from '../../graphql/types'; -import { DomainsBuckets, IpOverviewHit, UsersResponse } from './types'; +import { IpOverviewHit, UsersResponse } from './types'; export const responseAggs: IpOverviewHit = { aggregations: { @@ -252,75 +252,6 @@ export const formattedEmptySource = { }, }; -export const mockDomainsResponseBuckets: DomainsBuckets[] = [ - { - key: 'example.com', - uniqueIpCount: { - value: 805, - }, - lastSeen: { - value: 1554920919000, - value_as_string: '2019-04-10T18:28:39.000Z', - }, - bytes: { - value: 974964465, - }, - packets: { - value: 16946245, - }, - direction: { - buckets: [ - { - key: NetworkDirectionEcs.outbound, - doc_count: 51668, - }, - { - key: NetworkDirectionEcs.inbound, - doc_count: 25681, - }, - ], - }, - }, -]; - -export const mockFormattedSource: DomainsEdges[] = [ - { - cursor: { tiebreaker: null, value: 'example.com' }, - node: { - _id: 'example.com', - network: { - bytes: 974964465, - direction: [NetworkDirectionEcs.outbound, NetworkDirectionEcs.inbound], - packets: 16946245, - }, - source: { - domainName: 'example.com', - lastSeen: '2019-04-10T18:28:39.000Z', - uniqueIpCount: 805, - }, - }, - }, -]; - -export const mockFormattedDestination: DomainsEdges[] = [ - { - cursor: { tiebreaker: null, value: 'example.com' }, - node: { - _id: 'example.com', - destination: { - domainName: 'example.com', - lastSeen: '2019-04-10T18:28:39.000Z', - uniqueIpCount: 805, - }, - network: { - bytes: 974964465, - direction: [NetworkDirectionEcs.outbound, NetworkDirectionEcs.inbound], - packets: 16946245, - }, - }, - }, -]; - export const mockUsersData: UsersResponse = { took: 445, timed_out: false, diff --git a/x-pack/legacy/plugins/siem/server/lib/ip_details/query_domains.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/ip_details/query_domains.dsl.ts deleted file mode 100644 index e81a337458203..0000000000000 --- a/x-pack/legacy/plugins/siem/server/lib/ip_details/query_domains.dsl.ts +++ /dev/null @@ -1,204 +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 { - Direction, - DomainsFields, - DomainsSortField, - FlowDirection, - FlowTarget, -} from '../../graphql/types'; -import { assertUnreachable, createQueryFilterClauses } from '../../utils/build_query'; - -import { DomainsRequestOptions } from './index'; - -const getAggs = ( - ip: string, - flowTarget: FlowTarget, - flowDirection: FlowDirection, - domainsSortField: DomainsSortField, - querySize: number -) => { - return { - domain_count: { - cardinality: { - field: `${flowTarget}.domain`, - }, - }, - [`${flowTarget}_domains`]: { - terms: { - field: `${flowTarget}.domain`, - size: querySize, - order: { - ...getQueryOrder(domainsSortField), - }, - }, - aggs: { - lastSeen: { - max: { - field: '@timestamp', - }, - }, - bytes: { - sum: { - field: - flowDirection === FlowDirection.uniDirectional - ? 'network.bytes' - : `${flowTarget}.bytes`, - }, - }, - direction: { - terms: { - field: 'network.direction', - }, - }, - uniqueIpCount: { - cardinality: { - field: `${getOppositeField(flowTarget)}.ip`, - }, - }, - packets: { - sum: { - field: - flowDirection === FlowDirection.uniDirectional - ? 'network.packets' - : `${flowTarget}.packets`, - }, - }, - }, - }, - }; -}; - -const getUniDirectionalFilter = (flowDirection: FlowDirection) => - flowDirection === FlowDirection.uniDirectional - ? { - must_not: [ - { - exists: { - field: 'destination.bytes', - }, - }, - ], - } - : {}; - -const getBiDirectionalFilter = (flowDirection: FlowDirection, flowTarget: FlowTarget) => { - if ( - flowDirection === FlowDirection.biDirectional && - [FlowTarget.source, FlowTarget.destination].includes(flowTarget) - ) { - return [ - { - exists: { - field: 'source.bytes', - }, - }, - { - exists: { - field: 'destination.bytes', - }, - }, - ]; - } else if ( - flowDirection === FlowDirection.biDirectional && - [FlowTarget.client, FlowTarget.server].includes(flowTarget) - ) { - return [ - { - exists: { - field: 'client.bytes', - }, - }, - { - exists: { - field: 'server.bytes', - }, - }, - ]; - } - return []; -}; - -export const buildDomainsQuery = ({ - ip, - domainsSortField, - filterQuery, - flowDirection, - flowTarget, - pagination: { querySize }, - defaultIndex, - sourceConfiguration: { - fields: { timestamp }, - }, - timerange: { from, to }, -}: DomainsRequestOptions) => { - const filter = [ - ...createQueryFilterClauses(filterQuery), - { range: { [timestamp]: { gte: from, lte: to } } }, - { term: { [`${flowTarget}.ip`]: ip } }, - ...getBiDirectionalFilter(flowDirection, flowTarget), - ]; - - const dslQuery = { - allowNoIndices: true, - index: defaultIndex, - ignoreUnavailable: true, - body: { - aggs: { - ...getAggs(ip, flowTarget, flowDirection, domainsSortField, querySize), - }, - query: { - bool: { - filter, - ...getUniDirectionalFilter(flowDirection), - }, - }, - size: 0, - track_total_hits: false, - }, - }; - - return dslQuery; -}; - -const getOppositeField = (flowTarget: FlowTarget): FlowTarget => { - switch (flowTarget) { - case FlowTarget.source: - return FlowTarget.destination; - case FlowTarget.destination: - return FlowTarget.source; - case FlowTarget.server: - return FlowTarget.client; - case FlowTarget.client: - return FlowTarget.server; - default: - return assertUnreachable(flowTarget); - } -}; - -type QueryOrder = - | { _key: Direction } - | { bytes: Direction } - | { packets: Direction } - | { uniqueIpCount: Direction }; - -const getQueryOrder = (domainsSortField: DomainsSortField): QueryOrder => { - switch (domainsSortField.field) { - case DomainsFields.bytes: - return { bytes: domainsSortField.direction }; - case DomainsFields.packets: - return { packets: domainsSortField.direction }; - case DomainsFields.uniqueIpCount: - return { uniqueIpCount: domainsSortField.direction }; - case DomainsFields.domainName: - return { _key: domainsSortField.direction }; - case DomainsFields.direction: - return { _key: domainsSortField.direction }; - default: - return assertUnreachable(domainsSortField.field); - } -}; diff --git a/x-pack/legacy/plugins/siem/server/lib/ip_details/types.ts b/x-pack/legacy/plugins/siem/server/lib/ip_details/types.ts index a53d04544007d..be7519e64b651 100644 --- a/x-pack/legacy/plugins/siem/server/lib/ip_details/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/ip_details/types.ts @@ -4,14 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DomainsData, IpOverviewData, NetworkDirectionEcs, UsersData } from '../../graphql/types'; +import { TlsData, IpOverviewData, UsersData } from '../../graphql/types'; import { FrameworkRequest, RequestBasicOptions } from '../framework'; import { Hit, ShardsResponse, TotalValue } from '../types'; export interface IpDetailsAdapter { getIpDetails(request: FrameworkRequest, options: RequestBasicOptions): Promise; - getDomains(request: FrameworkRequest, options: RequestBasicOptions): Promise; - getTls(request: FrameworkRequest, options: RequestBasicOptions): Promise; + getTls(request: FrameworkRequest, options: RequestBasicOptions): Promise; getUsers(request: FrameworkRequest, options: RequestBasicOptions): Promise; } @@ -82,39 +81,6 @@ export interface IpOverviewHit { timeout: number; } -export interface DirectionBuckets { - key: NetworkDirectionEcs; - doc_count?: number; -} - -export interface DomainsBuckets { - key: string; - timestamp?: { - value: number; - value_as_string: string; - }; - uniqueIpCount: { - value: number; - }; - bytes: { - value: number; - }; - packets: { - value: number; - }; - direction: { - buckets: DirectionBuckets[]; - }; - firstSeen?: { - value: number; - value_as_string: string; - }; - lastSeen?: { - value: number; - value_as_string: string; - }; -} - export interface TlsBuckets { key: string; timestamp?: { diff --git a/x-pack/legacy/plugins/siem/server/lib/network/elastic_adapter.test.ts b/x-pack/legacy/plugins/siem/server/lib/network/elastic_adapter.test.ts index fcf29c1c283c6..c3bcfafac8757 100644 --- a/x-pack/legacy/plugins/siem/server/lib/network/elastic_adapter.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/network/elastic_adapter.test.ts @@ -6,11 +6,21 @@ import { cloneDeep } from 'lodash/fp'; -import { FlowTargetNew, NetworkTopNFlowData } from '../../graphql/types'; +import { FlowTargetSourceDest, NetworkTopNFlowData } from '../../graphql/types'; import { FrameworkAdapter, FrameworkRequest } from '../framework'; import { ElasticsearchNetworkAdapter } from './elasticsearch_adapter'; -import { mockOptions, mockRequest, mockResponse, mockResult, mockTopNFlowQueryDsl } from './mock'; +import { + mockOptions, + mockRequest, + mockResponse, + mockResult, + mockOptionsIp, + mockRequestIp, + mockResponseIp, + mockResultIp, + mockTopNFlowQueryDsl, +} from './mock'; jest.mock('./query_top_n_flow.dsl', () => { const r = jest.requireActual('./query_top_n_flow.dsl'); @@ -49,7 +59,7 @@ describe('Network Top N flow elasticsearch_adapter with FlowTarget=source', () = describe('Unhappy Path - No data', () => { const mockNoDataResponse = cloneDeep(mockResponse); mockNoDataResponse.aggregations.top_n_flow_count.value = 0; - mockNoDataResponse.aggregations[FlowTargetNew.source].buckets = []; + mockNoDataResponse.aggregations[FlowTargetSourceDest.source].buckets = []; const mockCallWithRequest = jest.fn(); mockCallWithRequest.mockResolvedValue(mockNoDataResponse); const mockFramework: FrameworkAdapter = { @@ -90,8 +100,11 @@ describe('Network Top N flow elasticsearch_adapter with FlowTarget=source', () = const mockNoPaginationResponse = cloneDeep(mockResponse); mockNoPaginationResponse.aggregations.top_n_flow_count.value = 10; mockNoPaginationResponse.aggregations[ - FlowTargetNew.source - ].buckets = mockNoPaginationResponse.aggregations[FlowTargetNew.source].buckets.slice(0, -1); + FlowTargetSourceDest.source + ].buckets = mockNoPaginationResponse.aggregations[FlowTargetSourceDest.source].buckets.slice( + 0, + -1 + ); const mockCallWithRequest = jest.fn(); mockCallWithRequest.mockResolvedValue(mockNoPaginationResponse); const mockFramework: FrameworkAdapter = { @@ -115,4 +128,29 @@ describe('Network Top N flow elasticsearch_adapter with FlowTarget=source', () = expect(data.pageInfo.showMorePagesIndicator).toBeFalsy(); }); }); + + describe('Filter by IP', () => { + const mockCallWithRequest = jest.fn(); + mockCallWithRequest.mockResolvedValue(mockResponseIp); + const mockFramework: FrameworkAdapter = { + version: 'mock', + callWithRequest: mockCallWithRequest, + exposeStaticDir: jest.fn(), + getIndexPatternsService: jest.fn(), + getSavedObjectsService: jest.fn(), + registerGraphQLEndpoint: jest.fn(), + }; + jest.doMock('../framework', () => ({ + callWithRequest: mockCallWithRequest, + })); + + test('getNetworkTopNFlow', async () => { + const EsNetworkTopNFlow = new ElasticsearchNetworkAdapter(mockFramework); + const data: NetworkTopNFlowData = await EsNetworkTopNFlow.getNetworkTopNFlow( + mockRequestIp as FrameworkRequest, + mockOptionsIp + ); + expect(data).toEqual(mockResultIp); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/network/elasticsearch_adapter.ts b/x-pack/legacy/plugins/siem/server/lib/network/elasticsearch_adapter.ts index 925e40de32f71..0b787a08cec17 100644 --- a/x-pack/legacy/plugins/siem/server/lib/network/elasticsearch_adapter.ts +++ b/x-pack/legacy/plugins/siem/server/lib/network/elasticsearch_adapter.ts @@ -7,9 +7,8 @@ import { get, getOr } from 'lodash/fp'; import { - FlowTargetNew, + FlowTargetSourceDest, AutonomousSystemItem, - FlowTarget, GeoItem, NetworkDnsData, NetworkDnsEdges, @@ -114,10 +113,10 @@ const getTopNFlowEdges = ( }; const getFlowTargetFromString = (flowAsString: string) => - flowAsString === 'source' ? FlowTarget.source : FlowTarget.destination; + flowAsString === 'source' ? FlowTargetSourceDest.source : FlowTargetSourceDest.destination; const getGeoItem = (result: NetworkTopNFlowBuckets): GeoItem | null => - result.location.top_geo.hits.hits.length > 0 + result.location.top_geo.hits.hits.length > 0 && result.location.top_geo.hits.hits[0]._source ? { geo: getOr( '', @@ -154,7 +153,7 @@ const getAsItem = (result: NetworkTopNFlowBuckets): AutonomousSystemItem | null const formatTopNFlowEdges = ( buckets: NetworkTopNFlowBuckets[], - flowTarget: FlowTargetNew + flowTarget: FlowTargetSourceDest ): NetworkTopNFlowEdges[] => buckets.map((bucket: NetworkTopNFlowBuckets) => ({ node: { diff --git a/x-pack/legacy/plugins/siem/server/lib/network/index.ts b/x-pack/legacy/plugins/siem/server/lib/network/index.ts index 774a6d02ce576..c183122af998f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/network/index.ts +++ b/x-pack/legacy/plugins/siem/server/lib/network/index.ts @@ -5,7 +5,8 @@ */ import { - FlowTargetNew, + FlowTargetSourceDest, + Maybe, NetworkDnsSortField, NetworkTopNFlowData, NetworkTopNFlowSortField, @@ -18,7 +19,8 @@ export * from './types'; export interface NetworkTopNFlowRequestOptions extends RequestOptionsPaginated { networkTopNFlowSort: NetworkTopNFlowSortField; - flowTarget: FlowTargetNew; + flowTarget: FlowTargetSourceDest; + ip?: Maybe; } export interface NetworkDnsRequestOptions extends RequestOptionsPaginated { diff --git a/x-pack/legacy/plugins/siem/server/lib/network/mock.ts b/x-pack/legacy/plugins/siem/server/lib/network/mock.ts index 2e36acefa0cdb..b0df45ab60a2c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/network/mock.ts +++ b/x-pack/legacy/plugins/siem/server/lib/network/mock.ts @@ -5,7 +5,7 @@ */ import { defaultIndexPattern } from '../../../default_index_pattern'; -import { Direction, FlowTargetNew, NetworkTopNFlowFields } from '../../graphql/types'; +import { Direction, FlowTargetSourceDest, NetworkTopNFlowFields } from '../../graphql/types'; import { NetworkTopNFlowRequestOptions } from '.'; @@ -55,7 +55,7 @@ export const mockOptions: NetworkTopNFlowRequestOptions = { '__typename', ], networkTopNFlowSort: { field: NetworkTopNFlowFields.bytes_out, direction: Direction.desc }, - flowTarget: FlowTargetNew.source, + flowTarget: FlowTargetSourceDest.source, }; export const mockRequest = { @@ -64,7 +64,7 @@ export const mockRequest = { operationName: 'GetNetworkTopNFlowQuery', variables: { filterQuery: '', - flowTarget: FlowTargetNew.source, + flowTarget: FlowTargetSourceDest.source, pagination: { activePage: 0, cursorStart: 0, @@ -74,56 +74,96 @@ export const mockRequest = { sourceId: 'default', timerange: { interval: '12h', from: 1549765830772, to: 1549852230772 }, }, - query: `query GetNetworkTopNFlowQuery($sourceId: ID!, $sort: NetworkTopNFlowSortField!, $flowTarget: FlowTargetNew!, $timerange: TimerangeInput!, $pagination: PaginationInput!, $filterQuery: String) { - source(id: $sourceId) { - id - NetworkTopNFlow(sort: $sort, flowTarget: $flowTarget, timerange: $timerange, pagination: $pagination, filterQuery: $filterQuery) { - totalCount - edges { - node { - source { - autonomous_system - domain - ip - location - flows - destination_ips - __typename + query: ` + query GetNetworkTopNFlowQuery( + $sourceId: ID! + $ip: String + $filterQuery: String + $pagination: PaginationInputPaginated! + $sort: NetworkTopNFlowSortField! + $flowTarget: FlowTargetSourceDest! + $timerange: TimerangeInput! + $defaultIndex: [String!]! + $inspect: Boolean! + ) { + source(id: $sourceId) { + id + NetworkTopNFlow( + filterQuery: $filterQuery + flowTarget: $flowTarget + ip: $ip + pagination: $pagination + sort: $sort + timerange: $timerange + defaultIndex: $defaultIndex + ) { + totalCount + edges { + node { + source { + autonomous_system { + name + number + } + domain + ip + location { + geo { + continent_name + country_name + country_iso_code + city_name + region_iso_code + region_name } - destination { - autonomous_system - domain - ip - location - source_ips - __typename - } - network { - bytes_in - bytes_out - __typename + flowTarget + } + flows + destination_ips + } + destination { + autonomous_system { + name + number + } + domain + ip + location { + geo { + continent_name + country_name + country_iso_code + city_name + region_iso_code + region_name } - __typename + flowTarget + } + flows + source_ips } - cursor { - value - __typename + network { + bytes_in + bytes_out } - __typename } - pageInfo { - activePage - __typename - fakeTotalCount - __typename - showMorePagesIndicator - __typename + cursor { + value } - __typename } - __typename + pageInfo { + activePage + fakeTotalCount + showMorePagesIndicator + } + inspect @include(if: $inspect) { + dsl + response + } } - }`, + } + } +`, }, }; @@ -144,7 +184,7 @@ export const mockResponse = { top_n_flow_count: { value: 545, }, - [FlowTargetNew.source]: { + [FlowTargetSourceDest.source]: { buckets: [ { key: '1.1.1.1', @@ -1459,3 +1499,178 @@ export const mockResult = { }, totalCount: 545, }; + +export const mockOptionsIp: NetworkTopNFlowRequestOptions = { + ...mockOptions, + ip: '1.1.1.1', +}; + +export const mockRequestIp = { + ...mockRequest, + payload: { + ...mockRequest.payload, + variables: { + ...mockRequest.payload.variables, + ip: '1.1.1.1', + }, + }, +}; + +export const mockResponseIp = { + took: 122, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + max_score: null, + hits: [], + }, + aggregations: { + top_n_flow_count: { + value: 1, + }, + [FlowTargetSourceDest.source]: { + buckets: [ + { + key: '1.1.1.1', + flows: { value: 1234567 }, + destination_ips: { value: 345345 }, + bytes_in: { + value: 11276023407, + }, + bytes_out: { + value: 1025631, + }, + location: { + doc_count: 14, + top_geo: { + hits: { + total: { + value: 14, + relation: 'eq', + }, + max_score: 1, + hits: [ + { + _index: 'filebeat-8.0.0-2019.06.19-000005', + _type: '_doc', + _id: 'dd4fa2d4bd-692279846149410', + _score: 1, + _source: { + source: { + geo: { + continent_name: 'North America', + region_iso_code: 'US-PA', + city_name: 'Philadelphia', + country_iso_code: 'US', + region_name: 'Pennsylvania', + location: { + lon: -75.1534, + lat: 39.9359, + }, + }, + }, + }, + }, + ], + }, + }, + }, + autonomous_system: { + doc_count: 14, + top_as: { + hits: { + total: { + value: 14, + relation: 'eq', + }, + max_score: 1, + hits: [ + { + _index: 'filebeat-8.0.0-2019.06.19-000005', + _type: '_doc', + _id: 'dd4fa2d4bd-692279846149410', + _score: 1, + _source: { + source: { + as: { + number: 3356, + organization: { + name: 'Level 3 Parent, LLC', + }, + }, + }, + }, + }, + ], + }, + }, + }, + domain: { + buckets: [ + { + key: 'test.1.net', + }, + ], + }, + }, + ], + }, + }, +}; + +export const mockResultIp = { + inspect: { + dsl: [JSON.stringify(mockTopNFlowQueryDsl, null, 2)], + response: [JSON.stringify(mockResponseIp, null, 2)], + }, + edges: [ + { + cursor: { + tiebreaker: null, + value: '1.1.1.1', + }, + node: { + _id: '1.1.1.1', + network: { + bytes_in: 11276023407, + bytes_out: 1025631, + }, + source: { + domain: ['test.1.net'], + ip: '1.1.1.1', + autonomous_system: { + name: 'Level 3 Parent, LLC', + number: 3356, + }, + location: { + flowTarget: 'source', + geo: { + city_name: 'Philadelphia', + continent_name: 'North America', + country_iso_code: 'US', + location: { + lat: 39.9359, + lon: -75.1534, + }, + region_iso_code: 'US-PA', + region_name: 'Pennsylvania', + }, + }, + flows: 1234567, + destination_ips: 345345, + }, + }, + }, + ], + pageInfo: { + activePage: 0, + fakeTotalCount: 1, + showMorePagesIndicator: false, + }, + totalCount: 1, +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/network/query_top_n_flow.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/network/query_top_n_flow.dsl.ts index acc921606c8e8..5fcd5bf5d7187 100644 --- a/x-pack/legacy/plugins/siem/server/lib/network/query_top_n_flow.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/network/query_top_n_flow.dsl.ts @@ -6,7 +6,7 @@ import { Direction, - FlowTargetNew, + FlowTargetSourceDest, NetworkTopNFlowSortField, NetworkTopNFlowFields, } from '../../graphql/types'; @@ -14,7 +14,7 @@ import { assertUnreachable, createQueryFilterClauses } from '../../utils/build_q import { NetworkTopNFlowRequestOptions } from './index'; -const getCountAgg = (flowTarget: FlowTargetNew) => ({ +const getCountAgg = (flowTarget: FlowTargetSourceDest) => ({ top_n_flow_count: { cardinality: { field: `${flowTarget}.ip`, @@ -32,6 +32,7 @@ export const buildTopNFlowQuery = ({ fields: { timestamp }, }, timerange: { from, to }, + ip, }: NetworkTopNFlowRequestOptions) => { const filter = [ ...createQueryFilterClauses(filterQuery), @@ -48,9 +49,21 @@ export const buildTopNFlowQuery = ({ ...getFlowTargetAggs(networkTopNFlowSort, flowTarget, querySize), }, query: { - bool: { - filter, - }, + bool: ip + ? { + filter, + should: [ + { + term: { + [`${getOppositeField(flowTarget)}.ip`]: ip, + }, + }, + ], + minimum_should_match: 1, + } + : { + filter, + }, }, }, size: 0, @@ -61,7 +74,7 @@ export const buildTopNFlowQuery = ({ const getFlowTargetAggs = ( networkTopNFlowSortField: NetworkTopNFlowSortField, - flowTarget: FlowTargetNew, + flowTarget: FlowTargetSourceDest, querySize: number ) => ({ [flowTarget]: { @@ -142,12 +155,12 @@ const getFlowTargetAggs = ( }, }); -export const getOppositeField = (flowTarget: FlowTargetNew): FlowTargetNew => { +export const getOppositeField = (flowTarget: FlowTargetSourceDest): FlowTargetSourceDest => { switch (flowTarget) { - case FlowTargetNew.source: - return FlowTargetNew.destination; - case FlowTargetNew.destination: - return FlowTargetNew.source; + case FlowTargetSourceDest.source: + return FlowTargetSourceDest.destination; + case FlowTargetSourceDest.destination: + return FlowTargetSourceDest.source; } assertUnreachable(flowTarget); }; diff --git a/x-pack/legacy/plugins/spaces/common/index.ts b/x-pack/legacy/plugins/spaces/common/index.ts index a0842201e0f08..8961c9c5ccf79 100644 --- a/x-pack/legacy/plugins/spaces/common/index.ts +++ b/x-pack/legacy/plugins/spaces/common/index.ts @@ -6,5 +6,3 @@ export { isReservedSpace } from './is_reserved_space'; export { MAX_SPACE_INITIALS } from './constants'; - -export { getSpaceIdFromPath, addSpaceIdToPath } from './lib/spaces_url_parser'; diff --git a/x-pack/legacy/plugins/spaces/index.ts b/x-pack/legacy/plugins/spaces/index.ts index a287aa2fcbb3f..a92fdcb9304cd 100644 --- a/x-pack/legacy/plugins/spaces/index.ts +++ b/x-pack/legacy/plugins/spaces/index.ts @@ -4,29 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as Rx from 'rxjs'; import { resolve } from 'path'; import KbnServer, { Server } from 'src/legacy/server/kbn_server'; -import { CoreSetup, PluginInitializerContext } from 'src/core/server'; +import { Legacy } from 'kibana'; +import { KibanaRequest } from '../../../../src/core/server'; +import { SpacesServiceSetup } from '../../../plugins/spaces/server/spaces_service/spaces_service'; +import { SpacesPluginSetup } from '../../../plugins/spaces/server'; import { createOptionalPlugin } from '../../server/lib/optional_plugin'; // @ts-ignore import { AuditLogger } from '../../server/lib/audit_logger'; import mappings from './mappings.json'; import { wrapError } from './server/lib/errors'; -import { getActiveSpace } from './server/lib/get_active_space'; import { migrateToKibana660 } from './server/lib/migrations'; -import { plugin } from './server/new_platform'; import { SecurityPlugin } from '../security'; -import { SpacesServiceSetup } from './server/new_platform/spaces_service/spaces_service'; +// @ts-ignore +import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; import { initSpaceSelectorView, initEnterSpaceView } from './server/routes/views'; -export interface SpacesPlugin { - getSpaceId: SpacesServiceSetup['getSpaceId']; - getActiveSpace: SpacesServiceSetup['getActiveSpace']; +export interface LegacySpacesPlugin { + getSpaceId: (request: Legacy.Request) => ReturnType; + getActiveSpace: (request: Legacy.Request) => ReturnType; spaceIdToNamespace: SpacesServiceSetup['spaceIdToNamespace']; namespaceToSpaceId: SpacesServiceSetup['namespaceToSpaceId']; getBasePath: SpacesServiceSetup['getBasePath']; - getScopedSpacesClient: SpacesServiceSetup['scopedClient']; } export const spaces = (kibana: Record) => @@ -36,13 +36,6 @@ export const spaces = (kibana: Record) => publicDir: resolve(__dirname, 'public'), require: ['kibana', 'elasticsearch', 'xpack_main'], - config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - maxSpaces: Joi.number().default(1000), - }).default(); - }, - uiCapabilities() { return { spaces: { @@ -92,18 +85,20 @@ export const spaces = (kibana: Record) => }, async replaceInjectedVars( vars: Record, - request: Record, + request: Legacy.Request, server: Record ) { - const spacesClient = await server.plugins.spaces.getScopedSpacesClient(request); + const spacesPlugin = server.newPlatform.setup.plugins.spaces as SpacesPluginSetup; + if (!spacesPlugin) { + throw new Error('New Platform XPack Spaces plugin is not available.'); + } + const kibanaRequest = KibanaRequest.from(request); + const spaceId = spacesPlugin.spacesService.getSpaceId(kibanaRequest); + const spacesClient = await spacesPlugin.spacesService.scopedClient(kibanaRequest); try { vars.activeSpace = { valid: true, - space: await getActiveSpace( - spacesClient, - request.getBasePath(), - server.config().get('server.basePath') - ), + space: await spacesClient.get(spaceId), }; } catch (e) { vars.activeSpace = { @@ -118,52 +113,18 @@ export const spaces = (kibana: Record) => async init(server: Server) { const kbnServer = (server as unknown) as KbnServer; - const initializerContext = { - config: { - create: () => { - return Rx.of({ - maxSpaces: server.config().get('xpack.spaces.maxSpaces'), - }); - }, - }, - logger: { - get(...contextParts: string[]) { - return kbnServer.newPlatform.coreContext.logger.get( - 'plugins', - 'spaces', - ...contextParts - ); - }, - }, - } as PluginInitializerContext; - const core = (kbnServer.newPlatform.setup.core as unknown) as CoreSetup; - - const plugins = { - xpackMain: server.plugins.xpack_main, - // TODO: Spaces has a circular dependency with Security right now. - // Security is not yet available when init runs, so this is wrapped in an optional function for the time being. - security: createOptionalPlugin( - server.config(), - 'xpack.security', - server.plugins, - 'security' - ), - spaces: this, - }; - - const { spacesService, registerLegacyAPI } = await plugin(initializerContext).setup( - core, - plugins - ); + const spacesPlugin = kbnServer.newPlatform.setup.plugins.spaces as SpacesPluginSetup; + if (!spacesPlugin) { + throw new Error('New Platform XPack Spaces plugin is not available.'); + } const config = server.config(); + const { registerLegacyAPI, createDefaultSpace } = spacesPlugin.__legacyCompat; + registerLegacyAPI({ - router: server.route.bind(server), legacyConfig: { - serverBasePath: config.get('server.basePath'), - serverDefaultRoute: config.get('server.defaultRoute'), kibanaIndex: config.get('kibana.index'), }, savedObjects: server.savedObjects, @@ -178,16 +139,30 @@ export const spaces = (kibana: Record) => create: (pluginId: string) => new AuditLogger(server, pluginId, server.config(), server.plugins.xpack_main.info), }, + security: createOptionalPlugin( + server.config(), + 'xpack.security', + server.plugins, + 'security' + ), + xpackMain: server.plugins.xpack_main, }); initEnterSpaceView(server); initSpaceSelectorView(server); - server.expose('getSpaceId', (request: any) => spacesService.getSpaceId(request)); - server.expose('getActiveSpace', spacesService.getActiveSpace); - server.expose('spaceIdToNamespace', spacesService.spaceIdToNamespace); - server.expose('namespaceToSpaceId', spacesService.namespaceToSpaceId); - server.expose('getBasePath', spacesService.getBasePath); - server.expose('getScopedSpacesClient', spacesService.scopedClient); + watchStatusAndLicenseToInitialize(server.plugins.xpack_main, this, async () => { + await createDefaultSpace(); + }); + + server.expose('getSpaceId', (request: Legacy.Request) => + spacesPlugin.spacesService.getSpaceId(request) + ); + server.expose('getActiveSpace', (request: Legacy.Request) => + spacesPlugin.spacesService.getActiveSpace(request) + ); + server.expose('spaceIdToNamespace', spacesPlugin.spacesService.spaceIdToNamespace); + server.expose('namespaceToSpaceId', spacesPlugin.spacesService.namespaceToSpaceId); + server.expose('getBasePath', spacesPlugin.spacesService.getBasePath); }, }); diff --git a/x-pack/legacy/plugins/spaces/public/lib/spaces_manager.ts b/x-pack/legacy/plugins/spaces/public/lib/spaces_manager.ts index e40e247e405fb..c1672e65326aa 100644 --- a/x-pack/legacy/plugins/spaces/public/lib/spaces_manager.ts +++ b/x-pack/legacy/plugins/spaces/public/lib/spaces_manager.ts @@ -10,7 +10,7 @@ import { Space } from '../../common/model/space'; import { GetSpacePurpose } from '../../common/model/types'; import { CopySavedObjectsToSpaceResponse } from './copy_saved_objects_to_space/types'; import { ENTER_SPACE_PATH } from '../../common/constants'; -import { addSpaceIdToPath } from '../../common'; +import { addSpaceIdToPath } from '../../../../../plugins/spaces/common'; export class SpacesManager extends EventEmitter { constructor(private readonly serverBasePath: string) { diff --git a/x-pack/legacy/plugins/spaces/server/lib/check_license.ts b/x-pack/legacy/plugins/spaces/server/lib/check_license.ts deleted file mode 100644 index 15dea834d2f15..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/lib/check_license.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. - */ - -export interface LicenseCheckResult { - showSpaces: boolean; -} - -/** - * Returns object that defines behavior of the spaces related features based - * on the license information extracted from the xPackInfo. - * @param {XPackInfo} xPackInfo XPackInfo instance to extract license information from. - * @returns {LicenseCheckResult} - */ -export function checkLicense(xPackInfo: any): LicenseCheckResult { - if (!xPackInfo.isAvailable()) { - return { - showSpaces: false, - }; - } - - const isAnyXpackLicense = xPackInfo.license.isOneOf([ - 'basic', - 'standard', - 'gold', - 'platinum', - 'trial', - ]); - - if (!isAnyXpackLicense) { - return { - showSpaces: false, - }; - } - - return { - showSpaces: true, - }; -} diff --git a/x-pack/legacy/plugins/spaces/server/lib/get_active_space.ts b/x-pack/legacy/plugins/spaces/server/lib/get_active_space.ts deleted file mode 100644 index a77a945239100..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/lib/get_active_space.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Space } from '../../common/model/space'; -import { wrapError } from './errors'; -import { SpacesClient } from './spaces_client'; -import { getSpaceIdFromPath } from '../../common'; - -export async function getActiveSpace( - spacesClient: SpacesClient, - requestBasePath: string, - serverBasePath: string -): Promise { - const spaceId = getSpaceIdFromPath(requestBasePath, serverBasePath); - - try { - return spacesClient.get(spaceId); - } catch (e) { - throw wrapError(e); - } -} diff --git a/x-pack/legacy/plugins/spaces/server/lib/route_pre_check_license.ts b/x-pack/legacy/plugins/spaces/server/lib/route_pre_check_license.ts deleted file mode 100644 index b29f55d6669aa..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/lib/route_pre_check_license.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; -import { XPackMainPlugin } from '../../../xpack_main/xpack_main'; - -interface LicenseCheckDeps { - xpackMain: XPackMainPlugin; -} - -export function routePreCheckLicense({ xpackMain }: LicenseCheckDeps) { - const pluginId = 'spaces'; - return function forbidApiAccess(request: any) { - const licenseCheckResults = xpackMain.info.feature(pluginId).getLicenseCheckResults(); - if (!licenseCheckResults.showSpaces) { - return Boom.forbidden(licenseCheckResults.linksMessage); - } else { - return ''; - } - }; -} diff --git a/x-pack/legacy/plugins/spaces/server/lib/space_schema.test.ts b/x-pack/legacy/plugins/spaces/server/lib/space_schema.test.ts deleted file mode 100644 index f3356c7f090f5..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/lib/space_schema.test.ts +++ /dev/null @@ -1,167 +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 { spaceSchema } from './space_schema'; - -const defaultProperties = { - id: 'foo', - name: 'foo', -}; - -describe('#id', () => { - test('is optional', () => { - const result = spaceSchema.validate({ - ...defaultProperties, - id: undefined, - }); - expect(result.error).toBeNull(); - }); - - test('allows lowercase a-z, 0-9, "_" and "-"', () => { - const result = spaceSchema.validate({ - ...defaultProperties, - id: 'abcdefghijklmnopqrstuvwxyz0123456789_-', - }); - expect(result.error).toBeNull(); - }); - - test(`doesn't allow uppercase`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - id: 'Foo', - }); - expect(result.error).toMatchInlineSnapshot( - `[ValidationError: child "id" fails because ["id" with value "Foo" fails to match the lower case, a-z, 0-9, "_", and "-" are allowed pattern]]` - ); - }); - - test(`doesn't allow an empty string`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - id: '', - }); - expect(result.error).toMatchInlineSnapshot( - `[ValidationError: child "id" fails because ["id" is not allowed to be empty]]` - ); - }); - - ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '+', ',', '.', '/', '?'].forEach( - invalidCharacter => { - test(`doesn't allow ${invalidCharacter}`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - id: `foo-${invalidCharacter}`, - }); - expect(result.error).toMatchObject({ - message: `child "id" fails because ["id" with value "foo-${invalidCharacter}" fails to match the lower case, a-z, 0-9, "_", and "-" are allowed pattern]`, - }); - }); - } - ); -}); - -describe('#color', () => { - test('is optional', () => { - const result = spaceSchema.validate({ - ...defaultProperties, - color: undefined, - }); - expect(result.error).toBeNull(); - }); - - test(`doesn't allow an empty string`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - color: '', - }); - expect(result.error).toMatchInlineSnapshot( - `[ValidationError: child "color" fails because ["color" is not allowed to be empty]]` - ); - }); - - test(`allows lower case hex color code`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - color: '#aabbcc', - }); - expect(result.error).toBeNull(); - }); - - test(`allows upper case hex color code`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - color: '#AABBCC', - }); - expect(result.error).toBeNull(); - }); - - test(`allows numeric hex color code`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - color: '#123456', - }); - expect(result.error).toBeNull(); - }); - - test(`must start with a hash`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - color: '123456', - }); - expect(result.error).toMatchInlineSnapshot( - `[ValidationError: child "color" fails because ["color" with value "123456" fails to match the 6 digit hex color, starting with a # pattern]]` - ); - }); - - test(`cannot exceed 6 digits following the hash`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - color: '1234567', - }); - expect(result.error).toMatchInlineSnapshot( - `[ValidationError: child "color" fails because ["color" with value "1234567" fails to match the 6 digit hex color, starting with a # pattern]]` - ); - }); - - test(`cannot be fewer than 6 digits following the hash`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - color: '12345', - }); - expect(result.error).toMatchInlineSnapshot( - `[ValidationError: child "color" fails because ["color" with value "12345" fails to match the 6 digit hex color, starting with a # pattern]]` - ); - }); -}); - -describe('#imageUrl', () => { - test('is optional', () => { - const result = spaceSchema.validate({ - ...defaultProperties, - imageUrl: undefined, - }); - expect(result.error).toBeNull(); - }); - - test(`must start with data:image`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - imageUrl: 'notValid', - }); - expect(result.error).toMatchInlineSnapshot( - `[ValidationError: child "imageUrl" fails because ["imageUrl" with value "notValid" fails to match the Image URL should start with 'data:image' pattern]]` - ); - }); - - test(`checking that a valid image is accepted as imageUrl`, () => { - const result = spaceSchema.validate({ - ...defaultProperties, - imageUrl: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTnU1rJkAAAB3klEQVRYR+2WzUrDQBCARzwqehE8ir1WPfgqRRA1bePBXgpe/MGCB9/Aiw+j+ASCB6kotklaEwW1F0WwNSaps9lV69awGzBpDzt8pJP9mXxsmk3ABH2oUEIilJAIJSRCCYlQQiKUkIh4QgY5agZodVjBowFrBktWQzDBU2ykiYaDuQpCYgnl3QunGzM6Z6YF+b5SkcgK1UH/aLbYReQiYL9d9/o+XFop5IU0Vl4uapAzoXC3eEBPw9vH1/wT6Vs2otPSkoH/IZzlzO/TU2vgQm8nl69Hp0H7nZ4OXogLJSSKBIUC3w88n+Ueyfv56fVZnqCQNVnCHbLrkV0Gd2d+GNkglsk438dhaTxloZDutV4wb06Vf40JcWZ2sMttPpE8NaHGeBnzIAhwPXqHseVB11EyLD0hxLUeaYud2a3B0g3k7GyFtrhX7F2RqhC+yV3jgTb2Rqdqf7/kUxYiWBOlTtXxfPJEtc8b5thGb+8AhL4ohnCNqQjZ2T2+K5rnw2M6KwEhKNDSGM3pTdxjhDgLbHkw/v/zw4AiPuSsfMzAiTidKxiF/ArpFqyzK8SMOlkwvloUMYRCtNvZLWeuIomd2Za/WZS4QomjhEQoIRFKSIQSEqGERAyfEH4YDBFQ/ARU6BiBxCAIQQAAAABJRU5ErkJggg==', - }); - expect(result.error).toBeNull(); - }); -}); diff --git a/x-pack/legacy/plugins/spaces/server/lib/space_schema.ts b/x-pack/legacy/plugins/spaces/server/lib/space_schema.ts deleted file mode 100644 index 2b8175b09794e..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/lib/space_schema.ts +++ /dev/null @@ -1,25 +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 Joi from 'joi'; -import { MAX_SPACE_INITIALS } from '../../common/constants'; - -export const SPACE_ID_REGEX = /^[a-z0-9_\-]+$/; - -export const spaceSchema = Joi.object({ - id: Joi.string().regex(SPACE_ID_REGEX, `lower case, a-z, 0-9, "_", and "-" are allowed`), - name: Joi.string().required(), - description: Joi.string().allow(''), - initials: Joi.string().max(MAX_SPACE_INITIALS), - color: Joi.string().regex(/^#[a-zA-Z0-9]{6}$/, `6 digit hex color, starting with a #`), - disabledFeatures: Joi.array() - .items(Joi.string()) - .default([]), - _reserved: Joi.boolean(), - imageUrl: Joi.string() - .allow('') - .regex(/^data:image.*$/, `Image URL should start with 'data:image'`), -}).default(); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts b/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts deleted file mode 100644 index 405a3dd34e7fc..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts +++ /dev/null @@ -1,309 +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 Rx from 'rxjs'; -import { Server } from 'hapi'; -import { Legacy } from 'kibana'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { elasticsearchServiceMock, coreMock } from 'src/core/server/mocks'; -import { SavedObjectsSchema, SavedObjectsLegacyService } from 'src/core/server'; -import { Readable } from 'stream'; -import { createPromiseFromStreams, createConcatStream } from 'src/legacy/utils/streams'; -import { createOptionalPlugin } from '../../../../../../server/lib/optional_plugin'; -import { SpacesClient } from '../../../lib/spaces_client'; -import { createSpaces } from './create_spaces'; -import { ExternalRouteDeps } from '../external'; -import { SpacesService } from '../../../new_platform/spaces_service'; -import { SpacesAuditLogger } from '../../../lib/audit_logger'; -import { LegacyAPI } from '../../../new_platform/plugin'; - -interface KibanaServer extends Legacy.Server { - savedObjects: any; -} - -export interface TestConfig { - [configKey: string]: any; -} - -export interface TestOptions { - setupFn?: (server: any) => void; - testConfig?: TestConfig; - payload?: any; - preCheckLicenseImpl?: (req: any, h: any) => any; - expectSpacesClientCall?: boolean; - expectPreCheckLicenseCall?: boolean; -} - -export type TeardownFn = () => void; - -export interface RequestRunnerResult { - server: any; - mockSavedObjectsRepository: any; - mockSavedObjectsService: { - getScopedSavedObjectsClient: jest.Mock< - SavedObjectsLegacyService['getScopedSavedObjectsClient'] - >; - importExport: { - getSortedObjectsForExport: jest.Mock< - SavedObjectsLegacyService['importExport']['getSortedObjectsForExport'] - >; - importSavedObjects: jest.Mock< - SavedObjectsLegacyService['importExport']['importSavedObjects'] - >; - resolveImportErrors: jest.Mock< - SavedObjectsLegacyService['importExport']['resolveImportErrors'] - >; - }; - }; - headers: Record; - response: any; -} - -export type RequestRunner = ( - method: string, - path: string, - options?: TestOptions -) => Promise; - -export const defaultPreCheckLicenseImpl = (request: any) => ''; - -const baseConfig: TestConfig = { - 'server.basePath': '', -}; - -async function readStreamToCompletion(stream: Readable) { - return (createPromiseFromStreams([stream, createConcatStream([])]) as unknown) as any[]; -} - -export function createTestHandler(initApiFn: (deps: ExternalRouteDeps) => void) { - const teardowns: TeardownFn[] = []; - - const spaces = createSpaces(); - - const request: RequestRunner = async ( - method: string, - path: string, - options: TestOptions = {} - ) => { - const { - setupFn = () => { - return; - }, - testConfig = {}, - payload, - preCheckLicenseImpl = defaultPreCheckLicenseImpl, - expectPreCheckLicenseCall = true, - expectSpacesClientCall = true, - } = options; - - let pre = jest.fn(); - if (preCheckLicenseImpl) { - pre = pre.mockImplementation(preCheckLicenseImpl); - } - - const server = new Server() as KibanaServer; - - const config = { - ...baseConfig, - ...testConfig, - }; - - await setupFn(server); - - const mockConfig = { - get: (key: string) => config[key], - }; - - server.decorate('server', 'config', jest.fn(() => mockConfig)); - - const mockSavedObjectsClientContract = { - get: jest.fn((type, id) => { - const result = spaces.filter(s => s.id === id); - if (!result.length) { - throw new Error(`not found: [${type}:${id}]`); - } - return result[0]; - }), - find: jest.fn(() => { - return { - total: spaces.length, - saved_objects: spaces, - }; - }), - create: jest.fn((type, attributes, { id }) => { - if (spaces.find(s => s.id === id)) { - throw new Error('conflict'); - } - return {}; - }), - update: jest.fn((type, id) => { - if (!spaces.find(s => s.id === id)) { - throw new Error('not found: during update'); - } - return {}; - }), - delete: jest.fn((type: string, id: string) => { - return {}; - }), - deleteByNamespace: jest.fn(), - }; - - server.savedObjects = { - types: ['visualization', 'dashboard', 'index-pattern', 'globalType'], - schema: new SavedObjectsSchema({ - space: { - isNamespaceAgnostic: true, - hidden: true, - }, - globalType: { - isNamespaceAgnostic: true, - }, - }), - getScopedSavedObjectsClient: jest.fn().mockResolvedValue(mockSavedObjectsClientContract), - importExport: { - getSortedObjectsForExport: jest.fn().mockResolvedValue( - new Readable({ - objectMode: true, - read() { - if (Array.isArray(payload.objects)) { - payload.objects.forEach((o: any) => this.push(o)); - } - this.push(null); - }, - }) - ), - importSavedObjects: jest.fn().mockImplementation(async (opts: Record) => { - const objectsToImport: any[] = await readStreamToCompletion(opts.readStream); - return { - success: true, - successCount: objectsToImport.length, - }; - }), - resolveImportErrors: jest.fn().mockImplementation(async (opts: Record) => { - const objectsToImport: any[] = await readStreamToCompletion(opts.readStream); - return { - success: true, - successCount: objectsToImport.length, - }; - }), - }, - SavedObjectsClient: { - errors: { - isNotFoundError: jest.fn((e: any) => e.message.startsWith('not found:')), - isConflictError: jest.fn((e: any) => e.message.startsWith('conflict')), - }, - }, - }; - - server.plugins.elasticsearch = { - createCluster: jest.fn(), - waitUntilReady: jest.fn(), - getCluster: jest.fn().mockReturnValue({ - callWithRequest: jest.fn(), - callWithInternalUser: jest.fn(), - }), - }; - - const log = { - log: jest.fn(), - trace: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - fatal: jest.fn(), - }; - - const coreSetupMock = coreMock.createSetup(); - - const legacyAPI = { - legacyConfig: { - serverBasePath: mockConfig.get('server.basePath'), - serverDefaultRoute: mockConfig.get('server.defaultRoute'), - }, - savedObjects: server.savedObjects, - } as LegacyAPI; - - const service = new SpacesService(log, () => legacyAPI); - const spacesService = await service.setup({ - http: coreSetupMock.http, - elasticsearch: elasticsearchServiceMock.createSetupContract(), - security: createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), - getSpacesAuditLogger: () => ({} as SpacesAuditLogger), - config$: Rx.of({ maxSpaces: 1000 }), - }); - - spacesService.scopedClient = jest.fn((req: any) => { - return Promise.resolve( - new SpacesClient( - null as any, - () => null, - null, - mockSavedObjectsClientContract, - { maxSpaces: 1000 }, - mockSavedObjectsClientContract, - req - ) - ); - }); - - initApiFn({ - routePreCheckLicenseFn: pre, - savedObjects: server.savedObjects, - spacesService, - log, - legacyRouter: server.route.bind(server), - }); - - teardowns.push(() => server.stop()); - - const headers = { - authorization: 'foo', - }; - - const testRun = async () => { - const response = await server.inject({ - method, - url: path, - headers, - payload, - }); - - if (preCheckLicenseImpl && expectPreCheckLicenseCall) { - expect(pre).toHaveBeenCalled(); - } else { - expect(pre).not.toHaveBeenCalled(); - } - - if (expectSpacesClientCall) { - expect(spacesService.scopedClient).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }) - ); - } else { - expect(spacesService.scopedClient).not.toHaveBeenCalled(); - } - - return response; - }; - - return { - server, - headers, - mockSavedObjectsRepository: mockSavedObjectsClientContract, - mockSavedObjectsService: server.savedObjects, - response: await testRun(), - }; - }; - - return { - request, - teardowns, - }; -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.test.ts deleted file mode 100644 index 292fc21a2dd79..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ /dev/null @@ -1,443 +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. - */ - -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.continue, - }; -}); - -jest.mock('../../../../../../server/lib/get_client_shield', () => { - return { - getClient: () => { - return { - callWithInternalUser: jest.fn(() => { - return; - }), - }; - }, - }; -}); - -import Boom from 'boom'; -import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; -import { initCopyToSpacesApi } from './copy_to_space'; - -describe('POST /api/spaces/_copy_saved_objects', () => { - let request: RequestRunner; - let teardowns: TeardownFn[]; - - beforeEach(() => { - const setup = createTestHandler(initCopyToSpacesApi); - - request = setup.request; - teardowns = setup.teardowns; - }); - - afterEach(async () => { - await Promise.all(teardowns.splice(0).map(fn => fn())); - }); - - test(`returns result of routePreCheckLicense`, async () => { - const payload = { - spaces: ['a-space'], - objects: [], - }; - - const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(403); - expect(JSON.parse(responsePayload)).toMatchObject({ - message: 'test forbidden message', - }); - }); - - test(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { - const payload = { - spaces: ['a-space'], - objects: [], - }; - - const { mockSavedObjectsService } = await request('POST', '/api/spaces/_copy_saved_objects', { - expectSpacesClientCall: false, - payload, - }); - - expect(mockSavedObjectsService.getScopedSavedObjectsClient).toHaveBeenCalledWith( - expect.any(Object), - { - excludedWrappers: ['spaces'], - } - ); - }); - - test(`requires space IDs to be unique`, async () => { - const payload = { - spaces: ['a-space', 'a-space'], - objects: [], - }; - - const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { - expectSpacesClientCall: false, - expectPreCheckLicenseCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "Invalid request payload input", - "statusCode": 400, - } - `); - }); - - test(`requires well-formed space IDS`, async () => { - const payload = { - spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'], - objects: [], - }; - - const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { - expectSpacesClientCall: false, - expectPreCheckLicenseCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "Invalid request payload input", - "statusCode": 400, - } - `); - }); - - test(`requires objects to be unique`, async () => { - const payload = { - spaces: ['a-space'], - objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }], - }; - - const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { - expectSpacesClientCall: false, - expectPreCheckLicenseCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "Invalid request payload input", - "statusCode": 400, - } - `); - }); - - test('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { - const payload = { - spaces: ['a-space'], - objects: [{ type: 'globalType', id: 'bar' }, { type: 'visualization', id: 'bar' }], - }; - - const { response, mockSavedObjectsService } = await request( - 'POST', - '/api/spaces/_copy_saved_objects', - { - expectSpacesClientCall: false, - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsService.importExport.importSavedObjects).toHaveBeenCalledTimes(1); - const [ - importCallOptions, - ] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[0]; - - expect(importCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); - }); - - test('copies to multiple spaces', async () => { - const payload = { - spaces: ['a-space', 'b-space'], - objects: [{ type: 'visualization', id: 'bar' }], - }; - - const { response, mockSavedObjectsService } = await request( - 'POST', - '/api/spaces/_copy_saved_objects', - { - expectSpacesClientCall: false, - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsService.importExport.importSavedObjects).toHaveBeenCalledTimes(2); - const [ - firstImportCallOptions, - ] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[0]; - - expect(firstImportCallOptions).toMatchObject({ - namespace: 'a-space', - }); - - const [ - secondImportCallOptions, - ] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[1]; - - expect(secondImportCallOptions).toMatchObject({ - namespace: 'b-space', - }); - }); -}); - -describe('POST /api/spaces/_resolve_copy_saved_objects_errors', () => { - let request: RequestRunner; - let teardowns: TeardownFn[]; - - beforeEach(() => { - const setup = createTestHandler(initCopyToSpacesApi); - - request = setup.request; - teardowns = setup.teardowns; - }); - - afterEach(async () => { - await Promise.all(teardowns.splice(0).map(fn => fn())); - }); - - test(`returns result of routePreCheckLicense`, async () => { - const payload = { - retries: {}, - objects: [], - }; - - const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(403); - expect(JSON.parse(responsePayload)).toMatchObject({ - message: 'test forbidden message', - }); - }); - - test(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { - const payload = { - retries: { - ['a-space']: [ - { - type: 'visualization', - id: 'bar', - overwrite: true, - }, - ], - }, - objects: [{ type: 'visualization', id: 'bar' }], - }; - - const { mockSavedObjectsService } = await request( - 'POST', - '/api/spaces/_resolve_copy_saved_objects_errors', - { - expectSpacesClientCall: false, - payload, - } - ); - - expect(mockSavedObjectsService.getScopedSavedObjectsClient).toHaveBeenCalledWith( - expect.any(Object), - { - excludedWrappers: ['spaces'], - } - ); - }); - - test(`requires objects to be unique`, async () => { - const payload = { - retries: {}, - objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }], - }; - - const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', { - expectSpacesClientCall: false, - expectPreCheckLicenseCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "Invalid request payload input", - "statusCode": 400, - } - `); - }); - - test(`requires well-formed space ids`, async () => { - const payload = { - retries: { - ['invalid-space-id!@#$%^&*()']: [ - { - type: 'foo', - id: 'bar', - overwrite: true, - }, - ], - }, - objects: [{ type: 'foo', id: 'bar' }], - }; - - const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', { - expectSpacesClientCall: false, - expectPreCheckLicenseCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "Invalid request payload input", - "statusCode": 400, - } - `); - }); - - test('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { - const payload = { - retries: { - ['a-space']: [ - { - type: 'visualization', - id: 'bar', - overwrite: true, - }, - { - type: 'globalType', - id: 'bar', - overwrite: true, - }, - ], - }, - objects: [ - { - type: 'globalType', - id: 'bar', - }, - { type: 'visualization', id: 'bar' }, - ], - }; - - const { response, mockSavedObjectsService } = await request( - 'POST', - '/api/spaces/_resolve_copy_saved_objects_errors', - { - expectSpacesClientCall: false, - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsService.importExport.resolveImportErrors).toHaveBeenCalledTimes(1); - const [ - resolveImportErrorsCallOptions, - ] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[0]; - - expect(resolveImportErrorsCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); - }); - - test('resolves conflicts for multiple spaces', async () => { - const payload = { - objects: [{ type: 'visualization', id: 'bar' }], - retries: { - ['a-space']: [ - { - type: 'visualization', - id: 'bar', - overwrite: true, - }, - ], - ['b-space']: [ - { - type: 'globalType', - id: 'bar', - overwrite: true, - }, - ], - }, - }; - - const { response, mockSavedObjectsService } = await request( - 'POST', - '/api/spaces/_resolve_copy_saved_objects_errors', - { - expectSpacesClientCall: false, - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsService.importExport.resolveImportErrors).toHaveBeenCalledTimes(2); - const [ - resolveImportErrorsFirstCallOptions, - ] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[0]; - - expect(resolveImportErrorsFirstCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); - - const [ - resolveImportErrorsSecondCallOptions, - ] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[1]; - - expect(resolveImportErrorsSecondCallOptions).toMatchObject({ - namespace: 'b-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); - }); -}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.ts deleted file mode 100644 index be5a921f91340..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.ts +++ /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 Joi from 'joi'; -import { Legacy } from 'kibana'; -import { - copySavedObjectsToSpacesFactory, - resolveCopySavedObjectsToSpacesConflictsFactory, -} from '../../../lib/copy_to_spaces'; -import { ExternalRouteDeps } from '.'; -import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from '../../../lib/copy_to_spaces/copy_to_spaces'; -import { SPACE_ID_REGEX } from '../../../lib/space_schema'; - -interface CopyPayload { - spaces: string[]; - objects: Array<{ type: string; id: string }>; - includeReferences: boolean; - overwrite: boolean; -} - -interface ResolveConflictsPayload { - objects: Array<{ type: string; id: string }>; - includeReferences: boolean; - retries: { - [spaceId: string]: Array<{ - type: string; - id: string; - overwrite: boolean; - }>; - }; -} - -export function initCopyToSpacesApi(deps: ExternalRouteDeps) { - const { legacyRouter, spacesService, savedObjects, routePreCheckLicenseFn } = deps; - - legacyRouter({ - method: 'POST', - path: '/api/spaces/_copy_saved_objects', - async handler(request: Legacy.Request, h: Legacy.ResponseToolkit) { - const savedObjectsClient = savedObjects.getScopedSavedObjectsClient( - request, - COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS - ); - - const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( - savedObjectsClient, - savedObjects - ); - - const { - spaces: destinationSpaceIds, - objects, - includeReferences, - overwrite, - } = request.payload as CopyPayload; - - const sourceSpaceId = spacesService.getSpaceId(request); - - const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { - objects, - includeReferences, - overwrite, - }); - - return h.response(copyResponse); - }, - options: { - tags: ['access:copySavedObjectsToSpaces'], - validate: { - payload: { - spaces: Joi.array() - .items( - Joi.string().regex(SPACE_ID_REGEX, `lower case, a-z, 0-9, "_", and "-" are allowed`) - ) - .unique(), - objects: Joi.array() - .items(Joi.object({ type: Joi.string(), id: Joi.string() })) - .unique(), - includeReferences: Joi.bool().default(false), - overwrite: Joi.bool().default(false), - }, - }, - pre: [routePreCheckLicenseFn], - }, - }); - - legacyRouter({ - method: 'POST', - path: '/api/spaces/_resolve_copy_saved_objects_errors', - async handler(request: Legacy.Request, h: Legacy.ResponseToolkit) { - const savedObjectsClient = savedObjects.getScopedSavedObjectsClient( - request, - COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS - ); - - const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( - savedObjectsClient, - savedObjects - ); - - const { objects, includeReferences, retries } = request.payload as ResolveConflictsPayload; - - const sourceSpaceId = spacesService.getSpaceId(request); - - const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts( - sourceSpaceId, - { - objects, - includeReferences, - retries, - } - ); - - return h.response(resolveConflictsResponse); - }, - options: { - tags: ['access:copySavedObjectsToSpaces'], - validate: { - payload: Joi.object({ - objects: Joi.array() - .items(Joi.object({ type: Joi.string(), id: Joi.string() })) - .required() - .unique(), - includeReferences: Joi.bool().default(false), - retries: Joi.object() - .pattern( - SPACE_ID_REGEX, - Joi.array().items( - Joi.object({ - type: Joi.string().required(), - id: Joi.string().required(), - overwrite: Joi.boolean().default(false), - }) - ) - ) - .required(), - }).default(), - }, - pre: [routePreCheckLicenseFn], - }, - }); -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/delete.test.ts deleted file mode 100644 index a1a23604f159a..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/delete.test.ts +++ /dev/null @@ -1,85 +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. - */ - -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.continue, - }; -}); - -jest.mock('../../../../../../server/lib/get_client_shield', () => { - return { - getClient: () => { - return { - callWithInternalUser: jest.fn(() => { - return; - }), - }; - }, - }; -}); -import Boom from 'boom'; -import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; -import { initDeleteSpacesApi } from './delete'; - -describe('Spaces Public API', () => { - let request: RequestRunner; - let teardowns: TeardownFn[]; - - beforeEach(() => { - const setup = createTestHandler(initDeleteSpacesApi); - - request = setup.request; - teardowns = setup.teardowns; - }); - - afterEach(async () => { - await Promise.all(teardowns.splice(0).map(fn => fn())); - }); - - test(`'DELETE spaces/{id}' deletes the space`, async () => { - const { response } = await request('DELETE', '/api/spaces/space/a-space'); - - const { statusCode } = response; - - expect(statusCode).toEqual(204); - }); - - test(`returns result of routePreCheckLicense`, async () => { - const { response } = await request('DELETE', '/api/spaces/space/a-space', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - }); - - const { statusCode, payload } = response; - - expect(statusCode).toEqual(403); - expect(JSON.parse(payload)).toMatchObject({ - message: 'test forbidden message', - }); - }); - - test('DELETE spaces/{id} throws when deleting a non-existent space', async () => { - const { response } = await request('DELETE', '/api/spaces/space/not-a-space'); - - const { statusCode } = response; - - expect(statusCode).toEqual(404); - }); - - test(`'DELETE spaces/{id}' cannot delete reserved spaces`, async () => { - const { response } = await request('DELETE', '/api/spaces/space/default'); - - const { statusCode, payload } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(payload)).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: 'This Space cannot be deleted because it is reserved.', - }); - }); -}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/delete.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/delete.ts deleted file mode 100644 index 720e932743e9a..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/delete.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 Boom from 'boom'; -import { wrapError } from '../../../lib/errors'; -import { SpacesClient } from '../../../lib/spaces_client'; -import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.'; - -export function initDeleteSpacesApi(deps: ExternalRouteDeps) { - const { legacyRouter, savedObjects, spacesService, routePreCheckLicenseFn } = deps; - - legacyRouter({ - method: 'DELETE', - path: '/api/spaces/space/{id}', - async handler(request: ExternalRouteRequestFacade, h: any) { - const { SavedObjectsClient } = savedObjects; - const spacesClient: SpacesClient = await spacesService.scopedClient(request); - - const id = request.params.id; - - let result; - - try { - result = await spacesClient.delete(id); - } catch (error) { - if (SavedObjectsClient.errors.isNotFoundError(error)) { - return Boom.notFound(); - } - return wrapError(error); - } - - return h.response(result).code(204); - }, - options: { - pre: [routePreCheckLicenseFn], - }, - }); -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/get.test.ts deleted file mode 100644 index 5357c38e0e9ae..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/get.test.ts +++ /dev/null @@ -1,109 +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. - */ - -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.continue, - }; -}); - -jest.mock('../../../../../../server/lib/get_client_shield', () => { - return { - getClient: () => { - return { - callWithInternalUser: jest.fn(() => { - return; - }), - }; - }, - }; -}); -import Boom from 'boom'; -import { Space } from '../../../../common/model/space'; -import { createSpaces, createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; -import { initGetSpacesApi } from './get'; - -describe('GET spaces', () => { - let request: RequestRunner; - let teardowns: TeardownFn[]; - const spaces = createSpaces(); - - beforeEach(() => { - const setup = createTestHandler(initGetSpacesApi); - - request = setup.request; - teardowns = setup.teardowns; - }); - - afterEach(async () => { - await Promise.all(teardowns.splice(0).map(fn => fn())); - }); - - test(`'GET spaces' returns all available spaces`, async () => { - const { response } = await request('GET', '/api/spaces/space'); - - const { statusCode, payload } = response; - - expect(statusCode).toEqual(200); - const resultSpaces: Space[] = JSON.parse(payload); - expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id)); - }); - - test(`'GET spaces' returns all available spaces with the 'any' purpose`, async () => { - const { response } = await request('GET', '/api/spaces/space?purpose=any'); - - const { statusCode, payload } = response; - - expect(statusCode).toEqual(200); - const resultSpaces: Space[] = JSON.parse(payload); - expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id)); - }); - - test(`'GET spaces' returns all available spaces with the 'copySavedObjectsIntoSpace' purpose`, async () => { - const { response } = await request( - 'GET', - '/api/spaces/space?purpose=copySavedObjectsIntoSpace' - ); - - const { statusCode, payload } = response; - - expect(statusCode).toEqual(200); - const resultSpaces: Space[] = JSON.parse(payload); - expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id)); - }); - - test(`returns result of routePreCheckLicense`, async () => { - const { response } = await request('GET', '/api/spaces/space', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - }); - - const { statusCode, payload } = response; - - expect(statusCode).toEqual(403); - expect(JSON.parse(payload)).toMatchObject({ - message: 'test forbidden message', - }); - }); - - test(`'GET spaces/{id}' returns the space with that id`, async () => { - const { response } = await request('GET', '/api/spaces/space/default'); - - const { statusCode, payload } = response; - - expect(statusCode).toEqual(200); - const resultSpace = JSON.parse(payload); - expect(resultSpace.id).toEqual('default'); - }); - - test(`'GET spaces/{id}' returns 404 when retrieving a non-existent space`, async () => { - const { response } = await request('GET', '/api/spaces/space/not-a-space'); - - const { statusCode } = response; - - expect(statusCode).toEqual(404); - }); -}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/get.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/get.ts deleted file mode 100644 index 310cef5c1069e..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/get.ts +++ /dev/null @@ -1,76 +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 Boom from 'boom'; -import Joi from 'joi'; -import { RequestQuery } from 'hapi'; -import { GetSpacePurpose } from '../../../../common/model/types'; -import { Space } from '../../../../common/model/space'; -import { wrapError } from '../../../lib/errors'; -import { SpacesClient } from '../../../lib/spaces_client'; -import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.'; - -export function initGetSpacesApi(deps: ExternalRouteDeps) { - const { legacyRouter, log, spacesService, savedObjects, routePreCheckLicenseFn } = deps; - - legacyRouter({ - method: 'GET', - path: '/api/spaces/space', - async handler(request: ExternalRouteRequestFacade) { - log.debug(`Inside GET /api/spaces/space`); - - const purpose: GetSpacePurpose = (request.query as RequestQuery).purpose as GetSpacePurpose; - - const spacesClient: SpacesClient = await spacesService.scopedClient(request); - - let spaces: Space[]; - - try { - log.debug(`Attempting to retrieve all spaces for ${purpose} purpose`); - spaces = await spacesClient.getAll(purpose); - log.debug(`Retrieved ${spaces.length} spaces for ${purpose} purpose`); - } catch (error) { - log.debug(`Error retrieving spaces for ${purpose} purpose: ${error}`); - return wrapError(error); - } - - return spaces; - }, - options: { - pre: [routePreCheckLicenseFn], - validate: { - query: Joi.object().keys({ - purpose: Joi.string() - .valid('any', 'copySavedObjectsIntoSpace') - .default('any'), - }), - }, - }, - }); - - legacyRouter({ - method: 'GET', - path: '/api/spaces/space/{id}', - async handler(request: ExternalRouteRequestFacade) { - const spaceId = request.params.id; - - const { SavedObjectsClient } = savedObjects; - const spacesClient: SpacesClient = await spacesService.scopedClient(request); - - try { - return await spacesClient.get(spaceId); - } catch (error) { - if (SavedObjectsClient.errors.isNotFoundError(error)) { - return Boom.notFound(); - } - return wrapError(error); - } - }, - options: { - pre: [routePreCheckLicenseFn], - }, - }); -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/index.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/index.ts deleted file mode 100644 index 7828a6b59d566..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/index.ts +++ /dev/null @@ -1,47 +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 { Legacy } from 'kibana'; -import { Logger, SavedObjectsLegacyService } from 'src/core/server'; -import { XPackMainPlugin } from '../../../../../xpack_main/xpack_main'; -import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; -import { initDeleteSpacesApi } from './delete'; -import { initGetSpacesApi } from './get'; -import { initPostSpacesApi } from './post'; -import { initPutSpacesApi } from './put'; -import { SpacesServiceSetup } from '../../../new_platform/spaces_service/spaces_service'; -import { initCopyToSpacesApi } from './copy_to_space'; - -type Omit = Pick>; - -interface RouteDeps { - xpackMain: XPackMainPlugin; - legacyRouter: Legacy.Server['route']; - savedObjects: SavedObjectsLegacyService; - spacesService: SpacesServiceSetup; - log: Logger; -} - -export interface ExternalRouteDeps extends Omit { - routePreCheckLicenseFn: any; -} - -export type ExternalRouteRequestFacade = Legacy.Request; - -export function initExternalSpacesApi({ xpackMain, ...rest }: RouteDeps) { - const routePreCheckLicenseFn = routePreCheckLicense({ xpackMain }); - - const deps: ExternalRouteDeps = { - ...rest, - routePreCheckLicenseFn, - }; - - initDeleteSpacesApi(deps); - initGetSpacesApi(deps); - initPostSpacesApi(deps); - initPutSpacesApi(deps); - initCopyToSpacesApi(deps); -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/post.test.ts deleted file mode 100644 index c53aaf29636a4..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/post.test.ts +++ /dev/null @@ -1,128 +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. - */ - -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.continue, - }; -}); - -jest.mock('../../../../../../server/lib/get_client_shield', () => { - return { - getClient: () => { - return { - callWithInternalUser: jest.fn(() => { - return; - }), - }; - }, - }; -}); - -import Boom from 'boom'; -import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; -import { initPostSpacesApi } from './post'; - -describe('Spaces Public API', () => { - let request: RequestRunner; - let teardowns: TeardownFn[]; - - beforeEach(() => { - const setup = createTestHandler(initPostSpacesApi); - - request = setup.request; - teardowns = setup.teardowns; - }); - - afterEach(async () => { - await Promise.all(teardowns.splice(0).map(fn => fn())); - }); - - test('POST /space should create a new space with the provided ID', async () => { - const payload = { - id: 'my-space-id', - name: 'my new space', - description: 'with a description', - disabledFeatures: ['foo'], - }; - - const { mockSavedObjectsRepository, response } = await request('POST', '/api/spaces/space', { - payload, - }); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsRepository.create).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsRepository.create).toHaveBeenCalledWith( - 'space', - { name: 'my new space', description: 'with a description', disabledFeatures: ['foo'] }, - { id: 'my-space-id' } - ); - }); - - test(`returns result of routePreCheckLicense`, async () => { - const payload = { - id: 'my-space-id', - name: 'my new space', - description: 'with a description', - }; - - const { response } = await request('POST', '/api/spaces/space', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(403); - expect(JSON.parse(responsePayload)).toMatchObject({ - message: 'test forbidden message', - }); - }); - - test('POST /space should not allow a space to be updated', async () => { - const payload = { - id: 'a-space', - name: 'my updated space', - description: 'with a description', - }; - - const { response } = await request('POST', '/api/spaces/space', { payload }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(409); - expect(JSON.parse(responsePayload)).toEqual({ - error: 'Conflict', - message: 'A space with the identifier a-space already exists.', - statusCode: 409, - }); - }); - - test('POST /space should not require disabledFeatures to be specified', async () => { - const payload = { - id: 'my-space-id', - name: 'my new space', - description: 'with a description', - }; - - const { mockSavedObjectsRepository, response } = await request('POST', '/api/spaces/space', { - payload, - }); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsRepository.create).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsRepository.create).toHaveBeenCalledWith( - 'space', - { name: 'my new space', description: 'with a description', disabledFeatures: [] }, - { id: 'my-space-id' } - ); - }); -}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/post.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/post.ts deleted file mode 100644 index 6a17b5c5eace6..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/post.ts +++ /dev/null @@ -1,45 +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 Boom from 'boom'; -import { Space } from '../../../../common/model/space'; -import { wrapError } from '../../../lib/errors'; -import { spaceSchema } from '../../../lib/space_schema'; -import { SpacesClient } from '../../../lib/spaces_client'; -import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.'; - -export function initPostSpacesApi(deps: ExternalRouteDeps) { - const { legacyRouter, log, spacesService, savedObjects, routePreCheckLicenseFn } = deps; - - legacyRouter({ - method: 'POST', - path: '/api/spaces/space', - async handler(request: ExternalRouteRequestFacade) { - log.debug(`Inside POST /api/spaces/space`); - const { SavedObjectsClient } = savedObjects; - const spacesClient: SpacesClient = await spacesService.scopedClient(request); - - const space = request.payload as Space; - - try { - log.debug(`Attempting to create space`); - return await spacesClient.create(space); - } catch (error) { - if (SavedObjectsClient.errors.isConflictError(error)) { - return Boom.conflict(`A space with the identifier ${space.id} already exists.`); - } - log.debug(`Error creating space: ${error}`); - return wrapError(error); - } - }, - options: { - validate: { - payload: spaceSchema, - }, - pre: [routePreCheckLicenseFn], - }, - }); -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/put.test.ts deleted file mode 100644 index d2ac1f89e1df9..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/put.test.ts +++ /dev/null @@ -1,156 +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. - */ -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.continue, - }; -}); - -jest.mock('../../../../../../server/lib/get_client_shield', () => { - return { - getClient: () => { - return { - callWithInternalUser: jest.fn(() => { - return; - }), - }; - }, - }; -}); -import Boom from 'boom'; -import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; -import { initPutSpacesApi } from './put'; - -describe('Spaces Public API', () => { - let request: RequestRunner; - let teardowns: TeardownFn[]; - - beforeEach(() => { - const setup = createTestHandler(initPutSpacesApi); - - request = setup.request; - teardowns = setup.teardowns; - }); - - afterEach(async () => { - await Promise.all(teardowns.splice(0).map(fn => fn())); - }); - - test('PUT /space should update an existing space with the provided ID', async () => { - const payload = { - id: 'a-space', - name: 'my updated space', - description: 'with a description', - disabledFeatures: [], - }; - - const { mockSavedObjectsRepository, response } = await request( - 'PUT', - '/api/spaces/space/a-space', - { - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledWith('space', 'a-space', { - name: 'my updated space', - description: 'with a description', - disabledFeatures: [], - }); - }); - - test('PUT /space should allow an empty description', async () => { - const payload = { - id: 'a-space', - name: 'my updated space', - description: '', - disabledFeatures: ['foo'], - }; - - const { mockSavedObjectsRepository, response } = await request( - 'PUT', - '/api/spaces/space/a-space', - { - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledWith('space', 'a-space', { - name: 'my updated space', - description: '', - disabledFeatures: ['foo'], - }); - }); - - test('PUT /space should not require disabledFeatures', async () => { - const payload = { - id: 'a-space', - name: 'my updated space', - description: '', - }; - - const { mockSavedObjectsRepository, response } = await request( - 'PUT', - '/api/spaces/space/a-space', - { - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledWith('space', 'a-space', { - name: 'my updated space', - description: '', - disabledFeatures: [], - }); - }); - - test(`returns result of routePreCheckLicense`, async () => { - const payload = { - id: 'a-space', - name: 'my updated space', - description: 'with a description', - }; - - const { response } = await request('PUT', '/api/spaces/space/a-space', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(403); - expect(JSON.parse(responsePayload)).toMatchObject({ - message: 'test forbidden message', - }); - }); - - test('PUT /space should not allow a new space to be created', async () => { - const payload = { - id: 'a-new-space', - name: 'my new space', - description: 'with a description', - }; - - const { response } = await request('PUT', '/api/spaces/space/a-new-space', { payload }); - - const { statusCode } = response; - - expect(statusCode).toEqual(404); - }); -}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/put.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/put.ts deleted file mode 100644 index 8e0f7673358d0..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/put.ts +++ /dev/null @@ -1,46 +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 Boom from 'boom'; -import { Space } from '../../../../common/model/space'; -import { wrapError } from '../../../lib/errors'; -import { spaceSchema } from '../../../lib/space_schema'; -import { SpacesClient } from '../../../lib/spaces_client'; -import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.'; - -export function initPutSpacesApi(deps: ExternalRouteDeps) { - const { legacyRouter, spacesService, savedObjects, routePreCheckLicenseFn } = deps; - - legacyRouter({ - method: 'PUT', - path: '/api/spaces/space/{id}', - async handler(request: ExternalRouteRequestFacade) { - const { SavedObjectsClient } = savedObjects; - const spacesClient: SpacesClient = await spacesService.scopedClient(request); - - const space: Space = request.payload as Space; - const id = request.params.id; - - let result: Space; - try { - result = await spacesClient.update(id, { ...space }); - } catch (error) { - if (SavedObjectsClient.errors.isNotFoundError(error)) { - return Boom.notFound(); - } - return wrapError(error); - } - - return result; - }, - options: { - validate: { - payload: spaceSchema, - }, - pre: [routePreCheckLicenseFn], - }, - }); -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/lib/get_space_by_id.ts b/x-pack/legacy/plugins/spaces/server/routes/lib/get_space_by_id.ts deleted file mode 100644 index eaa789b32c39b..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/lib/get_space_by_id.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { Space } from '../../../common/model/space'; -import { SpacesClient } from '../../lib/spaces_client'; -import { convertSavedObjectToSpace } from './convert_saved_object_to_space'; - -export async function getSpaceById( - client: SpacesClient, - spaceId: string, - errors: any -): Promise { - try { - const existingSpace = await client.get(spaceId); - return convertSavedObjectToSpace(existingSpace); - } catch (error) { - if (errors.isNotFoundError(error)) { - return null; - } - throw error; - } -} diff --git a/x-pack/legacy/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts b/x-pack/legacy/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts index e685b231a356d..4569fdfa33a83 100644 --- a/x-pack/legacy/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts +++ b/x-pack/legacy/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts @@ -299,9 +299,7 @@ describe('ReindexActions', () => { }); describe('runWhileConsumerLocked', () => { - Object.keys(IndexGroup).forEach(typeKey => { - const consumerType = IndexGroup[typeKey as any] as IndexGroup; - + Object.entries(IndexGroup).forEach(([typeKey, consumerType]) => { describe(`IndexConsumerType.${typeKey}`, () => { it('creates the lock doc if it does not exist and executes callback', async () => { expect.assertions(3); diff --git a/x-pack/legacy/plugins/uptime/public/apps/index.ts b/x-pack/legacy/plugins/uptime/public/apps/index.ts index 2fa548c3c2717..3b328b3ff2326 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/index.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/index.ts @@ -8,4 +8,4 @@ import chrome from 'ui/chrome'; import { npStart } from 'ui/new_platform'; import { Plugin } from './plugin'; -new Plugin({ opaqueId: Symbol('uptime') }, chrome).start(npStart); +new Plugin({ opaqueId: Symbol('uptime'), env: {} as any }, chrome).start(npStart); 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 b9ffd27d23022..002dc2862fa1b 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 @@ -38,8 +38,10 @@ export const findPotentialMatches = async ( // Here we grab the most recent 2 check groups per location and add them to the list. // Why 2? Because the most recent one may be a partial result from mode: all, and hence not match a summary doc. b.locations.buckets.forEach((lb: any) => { - lb.top.hits.hits.forEach((h: any) => { - checkGroups.add(h._source.monitor.check_group); + lb.ips.buckets.forEach((ib: any) => { + ib.top.hits.hits.forEach((h: any) => { + checkGroups.add(h._source.monitor.check_group); + }); }); }); } @@ -95,13 +97,18 @@ const queryBody = (queryContext: QueryContext, searchAfter: any, size: number) = locations: { terms: { field: 'observer.geo.name', missing: '__missing__' }, aggs: { - top: { - top_hits: { - sort: [{ '@timestamp': 'desc' }], - _source: { - includes: ['monitor.check_group', '@timestamp'], + ips: { + terms: { field: 'monitor.ip', missing: '0.0.0.0' }, + aggs: { + top: { + top_hits: { + sort: [{ '@timestamp': 'desc' }], + _source: { + includes: ['monitor.check_group', '@timestamp'], + }, + size: 2, + }, }, - size: 2, }, }, }, diff --git a/x-pack/legacy/server/lib/esjs_shield_plugin.js b/x-pack/legacy/server/lib/esjs_shield_plugin.js index 880e055c23985..b6252035aa321 100644 --- a/x-pack/legacy/server/lib/esjs_shield_plugin.js +++ b/x-pack/legacy/server/lib/esjs_shield_plugin.js @@ -502,6 +502,25 @@ ] }); + /** + * Gets API keys in Elasticsearch + * @param {boolean} owner A boolean flag that can be used to query API keys owned by the currently authenticated user. + * Defaults to false. The realm_name or username parameters cannot be specified when this parameter is set to true as + * they are assumed to be the currently authenticated ones. + */ + shield.getAPIKeys = ca({ + method: 'GET', + urls: [{ + fmt: `/_security/api_key?owner=<%=owner%>`, + req: { + owner: { + type: 'boolean', + required: true + } + } + }] + }); + /** * Creates an API key in Elasticsearch for the current user. * diff --git a/x-pack/plugins/spaces/common/constants.ts b/x-pack/plugins/spaces/common/constants.ts new file mode 100644 index 0000000000000..11882ca2f1b3a --- /dev/null +++ b/x-pack/plugins/spaces/common/constants.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DEFAULT_SPACE_ID = `default`; + +/** + * The minimum number of spaces required to show a search control. + */ +export const SPACE_SEARCH_COUNT_THRESHOLD = 8; + +/** + * The maximum number of characters allowed in the Space Avatar's initials + */ +export const MAX_SPACE_INITIALS = 2; + +/** + * The type name used within the Monitoring index to publish spaces stats. + * @type {string} + */ +export const KIBANA_SPACES_STATS_TYPE = 'spaces'; + +/** + * The path to enter a space. + */ +export const ENTER_SPACE_PATH = '/spaces/enter'; diff --git a/x-pack/plugins/spaces/common/index.ts b/x-pack/plugins/spaces/common/index.ts new file mode 100644 index 0000000000000..65baa1bd99102 --- /dev/null +++ b/x-pack/plugins/spaces/common/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { isReservedSpace } from './is_reserved_space'; +export { MAX_SPACE_INITIALS } from './constants'; +export { addSpaceIdToPath, getSpaceIdFromPath } from './lib/spaces_url_parser'; diff --git a/x-pack/plugins/spaces/common/is_reserved_space.test.ts b/x-pack/plugins/spaces/common/is_reserved_space.test.ts new file mode 100644 index 0000000000000..dd1372183ed8a --- /dev/null +++ b/x-pack/plugins/spaces/common/is_reserved_space.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 { isReservedSpace } from './is_reserved_space'; +import { Space } from './model/space'; + +test('it returns true for reserved spaces', () => { + const space: Space = { + id: '', + name: '', + disabledFeatures: [], + _reserved: true, + }; + + expect(isReservedSpace(space)).toEqual(true); +}); + +test('it returns false for non-reserved spaces', () => { + const space: Space = { + id: '', + name: '', + disabledFeatures: [], + }; + + expect(isReservedSpace(space)).toEqual(false); +}); + +test('it handles empty input', () => { + // @ts-ignore + expect(isReservedSpace()).toEqual(false); +}); diff --git a/x-pack/plugins/spaces/common/is_reserved_space.ts b/x-pack/plugins/spaces/common/is_reserved_space.ts new file mode 100644 index 0000000000000..788ef80c194ce --- /dev/null +++ b/x-pack/plugins/spaces/common/is_reserved_space.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 { get } from 'lodash'; +import { Space } from './model/space'; + +/** + * Returns whether the given Space is reserved or not. + * + * @param space the space + * @returns boolean + */ +export function isReservedSpace(space?: Partial | null): boolean { + return get(space, '_reserved', false); +} diff --git a/x-pack/legacy/plugins/spaces/common/lib/__snapshots__/spaces_url_parser.test.ts.snap b/x-pack/plugins/spaces/common/lib/__snapshots__/spaces_url_parser.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/spaces/common/lib/__snapshots__/spaces_url_parser.test.ts.snap rename to x-pack/plugins/spaces/common/lib/__snapshots__/spaces_url_parser.test.ts.snap diff --git a/x-pack/legacy/plugins/spaces/common/lib/spaces_url_parser.test.ts b/x-pack/plugins/spaces/common/lib/spaces_url_parser.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/common/lib/spaces_url_parser.test.ts rename to x-pack/plugins/spaces/common/lib/spaces_url_parser.test.ts diff --git a/x-pack/legacy/plugins/spaces/common/lib/spaces_url_parser.ts b/x-pack/plugins/spaces/common/lib/spaces_url_parser.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/common/lib/spaces_url_parser.ts rename to x-pack/plugins/spaces/common/lib/spaces_url_parser.ts diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/index.ts b/x-pack/plugins/spaces/common/model/space.ts similarity index 54% rename from x-pack/legacy/plugins/spaces/server/new_platform/index.ts rename to x-pack/plugins/spaces/common/model/space.ts index edf27e2dd819b..c44ce41ec51c0 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/index.ts +++ b/x-pack/plugins/spaces/common/model/space.ts @@ -4,9 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from 'src/core/server'; -import { Plugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new Plugin(initializerContext); +export interface Space { + id: string; + name: string; + description?: string; + color?: string; + initials?: string; + disabledFeatures: string[]; + _reserved?: boolean; + imageUrl?: string; } diff --git a/x-pack/plugins/spaces/common/model/types.ts b/x-pack/plugins/spaces/common/model/types.ts new file mode 100644 index 0000000000000..58c36da33dbd7 --- /dev/null +++ b/x-pack/plugins/spaces/common/model/types.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 type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace'; diff --git a/x-pack/plugins/spaces/kibana.json b/x-pack/plugins/spaces/kibana.json new file mode 100644 index 0000000000000..15d900bf99e14 --- /dev/null +++ b/x-pack/plugins/spaces/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "spaces", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack", "spaces"], + "requiredPlugins": ["features", "licensing"], + "server": true, + "ui": false +} diff --git a/x-pack/plugins/spaces/server/config.ts b/x-pack/plugins/spaces/server/config.ts new file mode 100644 index 0000000000000..a28624fb82c15 --- /dev/null +++ b/x-pack/plugins/spaces/server/config.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 { schema, TypeOf } from '@kbn/config-schema'; +import { PluginInitializerContext } from 'src/core/server'; +import { Observable } from 'rxjs'; + +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + maxSpaces: schema.number({ defaultValue: 1000 }), +}); + +export function createConfig$(context: PluginInitializerContext) { + return context.config.create>(); +} + +export type ConfigType = ReturnType extends Observable + ? P + : ReturnType; diff --git a/x-pack/plugins/spaces/server/index.ts b/x-pack/plugins/spaces/server/index.ts new file mode 100644 index 0000000000000..21d6c840fb017 --- /dev/null +++ b/x-pack/plugins/spaces/server/index.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 { PluginInitializerContext } from '../../../../src/core/server'; +import { ConfigSchema } from './config'; +import { Plugin } from './plugin'; + +// These exports are part of public Spaces plugin contract, any change in signature of exported +// functions or removal of exports should be considered as a breaking change. Ideally we should +// reduce number of such exports to zero and provide everything we want to expose via Setup/Start +// run-time contracts. + +// end public contract exports + +export { SpacesPluginSetup } from './plugin'; + +export const config = { schema: ConfigSchema }; +export const plugin = (initializerContext: PluginInitializerContext) => + new Plugin(initializerContext); diff --git a/x-pack/plugins/spaces/server/lib/__fixtures__/index.ts b/x-pack/plugins/spaces/server/lib/__fixtures__/index.ts new file mode 100644 index 0000000000000..3ac2b4594049f --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/__fixtures__/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 { spacesConfig } from './spaces_config'; diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/config.ts b/x-pack/plugins/spaces/server/lib/__fixtures__/spaces_config.ts similarity index 51% rename from x-pack/legacy/plugins/spaces/server/new_platform/config.ts rename to x-pack/plugins/spaces/server/lib/__fixtures__/spaces_config.ts index fbe8edb14f19b..1697b9d59af8a 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/config.ts +++ b/x-pack/plugins/spaces/server/lib/__fixtures__/spaces_config.ts @@ -4,12 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { ConfigSchema } from '../../config'; -export const config = { - schema: schema.object({ - maxSpaces: schema.number({ defaultValue: 1000 }), - }), -}; - -export type SpacesConfigType = TypeOf; +export const spacesConfig = ConfigSchema.validate({}); diff --git a/x-pack/legacy/plugins/spaces/server/lib/__snapshots__/create_default_space.test.ts.snap b/x-pack/plugins/spaces/server/lib/__snapshots__/create_default_space.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/__snapshots__/create_default_space.test.ts.snap rename to x-pack/plugins/spaces/server/lib/__snapshots__/create_default_space.test.ts.snap diff --git a/x-pack/legacy/plugins/spaces/server/lib/audit_logger.test.ts b/x-pack/plugins/spaces/server/lib/audit_logger.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/audit_logger.test.ts rename to x-pack/plugins/spaces/server/lib/audit_logger.test.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/audit_logger.ts b/x-pack/plugins/spaces/server/lib/audit_logger.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/audit_logger.ts rename to x-pack/plugins/spaces/server/lib/audit_logger.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/index.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/index.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/index.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/index.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/create_empty_failure_response.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/create_empty_failure_response.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/create_empty_failure_response.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/create_empty_failure_response.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/read_stream_to_completion.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/read_stream_to_completion.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/read_stream_to_completion.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/read_stream_to_completion.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/readable_stream_from_array.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/readable_stream_from_array.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/readable_stream_from_array.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/readable_stream_from_array.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/types.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/create_default_space.test.ts b/x-pack/plugins/spaces/server/lib/create_default_space.test.ts similarity index 86% rename from x-pack/legacy/plugins/spaces/server/lib/create_default_space.test.ts rename to x-pack/plugins/spaces/server/lib/create_default_space.test.ts index cd0ecdea97fb2..8486508c45364 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/create_default_space.test.ts +++ b/x-pack/plugins/spaces/server/lib/create_default_space.test.ts @@ -3,23 +3,11 @@ * 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('../../../../server/lib/get_client_shield', () => ({ - getClient: jest.fn(), -})); -import * as Rx from 'rxjs'; + import Boom from 'boom'; -import { getClient } from '../../../../server/lib/get_client_shield'; import { createDefaultSpace } from './create_default_space'; -import { SavedObjectsLegacyService } from 'src/core/server'; -import { ElasticsearchServiceSetup } from 'src/core/server'; - -let mockCallWithRequest; -beforeEach(() => { - mockCallWithRequest = jest.fn(); - (getClient as jest.Mock).mockReturnValue({ - callWithRequest: mockCallWithRequest, - }); -}); +import { SavedObjectsLegacyService, IClusterClient } from 'src/core/server'; + interface MockServerSettings { defaultExists?: boolean; simulateGetErrorCondition?: boolean; @@ -84,11 +72,9 @@ const createMockDeps = (settings: MockServerSettings = {}) => { return { config: mockServer.config(), savedObjects: (mockServer.savedObjects as unknown) as SavedObjectsLegacyService, - elasticsearch: ({ - dataClient$: Rx.of({ - callAsInternalUser: jest.fn(), - }), - } as unknown) as ElasticsearchServiceSetup, + esClient: ({ + callAsInternalUser: jest.fn(), + } as unknown) as jest.Mocked, }; }; diff --git a/x-pack/legacy/plugins/spaces/server/lib/create_default_space.ts b/x-pack/plugins/spaces/server/lib/create_default_space.ts similarity index 80% rename from x-pack/legacy/plugins/spaces/server/lib/create_default_space.ts rename to x-pack/plugins/spaces/server/lib/create_default_space.ts index 9e574c19c987f..2301fa26dab28 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/create_default_space.ts +++ b/x-pack/plugins/spaces/server/lib/create_default_space.ts @@ -5,22 +5,18 @@ */ import { i18n } from '@kbn/i18n'; - -import { first } from 'rxjs/operators'; -import { SavedObjectsLegacyService, CoreSetup } from 'src/core/server'; +import { SavedObjectsLegacyService, IClusterClient } from 'src/core/server'; import { DEFAULT_SPACE_ID } from '../../common/constants'; interface Deps { - elasticsearch: CoreSetup['elasticsearch']; + esClient: Pick; savedObjects: SavedObjectsLegacyService; } -export async function createDefaultSpace({ elasticsearch, savedObjects }: Deps) { +export async function createDefaultSpace({ esClient, savedObjects }: Deps) { const { getSavedObjectsRepository, SavedObjectsClient } = savedObjects; - const client = await elasticsearch.dataClient$.pipe(first()).toPromise(); - - const savedObjectsRepository = getSavedObjectsRepository(client.callAsInternalUser, ['space']); + const savedObjectsRepository = getSavedObjectsRepository(esClient.callAsInternalUser, ['space']); const defaultSpaceExists = await doesDefaultSpaceExist( SavedObjectsClient, diff --git a/x-pack/plugins/spaces/server/lib/errors.ts b/x-pack/plugins/spaces/server/lib/errors.ts new file mode 100644 index 0000000000000..d800020038a38 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/errors.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 { boomify, isBoom } from 'boom'; +import { ResponseError, CustomHttpResponseOptions } from 'src/core/server'; + +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/legacy/plugins/spaces/server/lib/get_space_selector_url.test.ts b/x-pack/plugins/spaces/server/lib/get_space_selector_url.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/get_space_selector_url.test.ts rename to x-pack/plugins/spaces/server/lib/get_space_selector_url.test.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/get_space_selector_url.ts b/x-pack/plugins/spaces/server/lib/get_space_selector_url.ts similarity index 81% rename from x-pack/legacy/plugins/spaces/server/lib/get_space_selector_url.ts rename to x-pack/plugins/spaces/server/lib/get_space_selector_url.ts index 6d088fda757de..8cc641bc2e7cf 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/get_space_selector_url.ts +++ b/x-pack/plugins/spaces/server/lib/get_space_selector_url.ts @@ -4,6 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export function getSpaceSelectorUrl(serverBasePath: string = '') { +export function getSpaceSelectorUrl(serverBasePath: string) { return `${serverBasePath}/spaces/space_selector`; } diff --git a/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts similarity index 62% rename from x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts rename to x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts index 6609ca42a7f67..912cccbc01782 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts +++ b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts @@ -5,12 +5,24 @@ */ import { getSpacesUsageCollector, UsageStats } from './get_spaces_usage_collector'; +import * as Rx from 'rxjs'; +import { PluginsSetup } from '../plugin'; +import { Feature } from '../../../features/server'; +import { ILicense, LicensingPluginSetup } from '../../../licensing/server'; + +interface SetupOpts { + license?: Partial; + features?: Feature[]; +} -function getServerMock(customization?: any) { +function setup({ + license = { isAvailable: true }, + features = [{ id: 'feature1' } as Feature, { id: 'feature2' } as Feature], +}: SetupOpts = {}) { class MockUsageCollector { private fetch: any; - constructor(server: any, { fetch }: any) { + constructor({ fetch }: any) { this.fetch = fetch; } // to make typescript happy @@ -19,39 +31,23 @@ function getServerMock(customization?: any) { } } - const getLicenseCheckResults = jest.fn().mockReturnValue({}); - const defaultServerMock = { - plugins: { - xpack_main: { - info: { - isAvailable: jest.fn().mockReturnValue(true), - feature: () => ({ - getLicenseCheckResults, - }), - license: { - isOneOf: jest.fn().mockReturnValue(false), - getType: jest.fn().mockReturnValue('platinum'), - }, - toJSON: () => ({ b: 1 }), - }, - getFeatures: jest.fn().mockReturnValue([{ id: 'feature1' }, { id: 'feature2' }]), - }, - }, - expose: () => { - return; - }, - log: () => { - return; - }, + const licensing = { + license$: Rx.of(license), + } as LicensingPluginSetup; + + const featuresSetup = ({ + getFeatures: jest.fn().mockReturnValue(features), + } as unknown) as PluginsSetup['features']; + + return { + licensing, + features: featuresSetup, usage: { collectorSet: { - makeUsageCollector: (options: any) => { - return new MockUsageCollector(defaultServerMock, options); - }, + makeUsageCollector: (options: any) => new MockUsageCollector(options), }, }, }; - return Object.assign(defaultServerMock, customization); } const defaultCallClusterMock = jest.fn().mockResolvedValue({ @@ -73,17 +69,14 @@ const defaultCallClusterMock = jest.fn().mockResolvedValue({ }); describe('with a basic license', () => { - let serverWithBasicLicenseMock: any; let usageStats: UsageStats; beforeAll(async () => { - serverWithBasicLicenseMock = getServerMock(); - serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = jest - .fn() - .mockReturnValue('basic'); + const { features, licensing, usage } = setup({ license: { isAvailable: true, type: 'basic' } }); const { fetch: getSpacesUsage } = getSpacesUsageCollector({ kibanaIndex: '.kibana', - usage: serverWithBasicLicenseMock.usage, - xpackMain: serverWithBasicLicenseMock.plugins.xpack_main, + usage, + features, + licensing, }); usageStats = await getSpacesUsage(defaultCallClusterMock); }); @@ -113,13 +106,12 @@ describe('with a basic license', () => { describe('with no license', () => { let usageStats: UsageStats; beforeAll(async () => { - const serverWithNoLicenseMock = getServerMock(); - serverWithNoLicenseMock.plugins.xpack_main.info.isAvailable = jest.fn().mockReturnValue(false); - + const { features, licensing, usage } = setup({ license: { isAvailable: false } }); const { fetch: getSpacesUsage } = getSpacesUsageCollector({ kibanaIndex: '.kibana', - usage: serverWithNoLicenseMock.usage, - xpackMain: serverWithNoLicenseMock.plugins.xpack_main, + usage, + features, + licensing, }); usageStats = await getSpacesUsage(defaultCallClusterMock); }); @@ -142,17 +134,16 @@ describe('with no license', () => { }); describe('with platinum license', () => { - let serverWithPlatinumLicenseMock: any; let usageStats: UsageStats; beforeAll(async () => { - serverWithPlatinumLicenseMock = getServerMock(); - serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = jest - .fn() - .mockReturnValue('platinum'); + const { features, licensing, usage } = setup({ + license: { isAvailable: true, type: 'platinum' }, + }); const { fetch: getSpacesUsage } = getSpacesUsageCollector({ kibanaIndex: '.kibana', - usage: serverWithPlatinumLicenseMock.usage, - xpackMain: serverWithPlatinumLicenseMock.plugins.xpack_main, + usage, + features, + licensing, }); usageStats = await getSpacesUsage(defaultCallClusterMock); }); diff --git a/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.ts b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.ts similarity index 83% rename from x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.ts rename to x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.ts index 623f613faaa0c..bfbc5e6ab775d 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.ts +++ b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.ts @@ -6,10 +6,11 @@ import { get } from 'lodash'; import { CallAPIOptions } from 'src/core/server'; -import { XPackMainPlugin } from '../../../xpack_main/xpack_main'; +import { take } from 'rxjs/operators'; // @ts-ignore -import { KIBANA_STATS_TYPE_MONITORING } from '../../../monitoring/common/constants'; +import { KIBANA_STATS_TYPE_MONITORING } from '../../../../legacy/plugins/monitoring/common/constants'; import { KIBANA_SPACES_STATS_TYPE } from '../../common/constants'; +import { PluginsSetup } from '../plugin'; type CallCluster = ( endpoint: string, @@ -30,22 +31,23 @@ interface SpacesAggregationResponse { /** * - * @param callCluster - * @param server + * @param {CallCluster} callCluster + * @param {string} kibanaIndex + * @param {PluginsSetup['features']} features * @param {boolean} spacesAvailable * @return {UsageStats} */ async function getSpacesUsage( callCluster: CallCluster, kibanaIndex: string, - xpackMainPlugin: XPackMainPlugin, + features: PluginsSetup['features'], spacesAvailable: boolean ) { if (!spacesAvailable) { return {} as UsageStats; } - const knownFeatureIds = xpackMainPlugin.getFeatures().map(feature => feature.id); + const knownFeatureIds = features.getFeatures().map(feature => feature.id); const resp = await callCluster('search', { index: kibanaIndex, @@ -115,7 +117,8 @@ export interface UsageStats { interface CollectorDeps { kibanaIndex: string; usage: { collectorSet: any }; - xpackMain: XPackMainPlugin; + features: PluginsSetup['features']; + licensing: PluginsSetup['licensing']; } /* @@ -128,13 +131,13 @@ export function getSpacesUsageCollector(deps: CollectorDeps) { type: KIBANA_SPACES_STATS_TYPE, isReady: () => true, fetch: async (callCluster: CallCluster) => { - const xpackInfo = deps.xpackMain.info; - const available = xpackInfo && xpackInfo.isAvailable(); // some form of spaces is available for all valid licenses + const license = await deps.licensing.license$.pipe(take(1)).toPromise(); + const available = license.isAvailable; // some form of spaces is available for all valid licenses const usageStats = await getSpacesUsage( callCluster, deps.kibanaIndex, - deps.xpackMain, + deps.features, available ); diff --git a/x-pack/plugins/spaces/server/lib/migrations/index.ts b/x-pack/plugins/spaces/server/lib/migrations/index.ts new file mode 100644 index 0000000000000..b303a8489ffb0 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/migrations/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 { migrateToKibana660 } from './migrate_6x'; diff --git a/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.test.ts b/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.test.ts new file mode 100644 index 0000000000000..964eb8137685f --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.test.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 { migrateToKibana660 } from './migrate_6x'; + +describe('migrateTo660', () => { + it('adds a "disabledFeatures" attribute initialized as an empty array', () => { + expect( + migrateToKibana660({ + id: 'space:foo', + attributes: {}, + }) + ).toEqual({ + id: 'space:foo', + attributes: { + disabledFeatures: [], + }, + }); + }); + + it('does not initialize "disabledFeatures" if the property already exists', () => { + // This scenario shouldn't happen organically. Protecting against defects in the migration. + expect( + migrateToKibana660({ + id: 'space:foo', + attributes: { + disabledFeatures: ['foo', 'bar', 'baz'], + }, + }) + ).toEqual({ + id: 'space:foo', + attributes: { + disabledFeatures: ['foo', 'bar', 'baz'], + }, + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.ts b/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.ts new file mode 100644 index 0000000000000..0c080a8dabb0a --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function migrateToKibana660(doc: Record) { + if (!doc.attributes.hasOwnProperty('disabledFeatures')) { + doc.attributes.disabledFeatures = []; + } + return doc; +} diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/index.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/index.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/request_interceptors/index.ts rename to x-pack/plugins/spaces/server/lib/request_interceptors/index.ts diff --git a/x-pack/legacy/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 similarity index 94% rename from x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts rename to x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index 7500c8acadb7e..1f20fee46ba4c 100644 --- a/x-pack/legacy/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 @@ -15,26 +15,25 @@ import { SavedObjectsErrorHelpers, IBasePath, IRouter, -} from '../../../../../../../src/core/server'; +} from '../../../../../../src/core/server'; import { elasticsearchServiceMock, loggingServiceMock, -} from '../../../../../../../src/core/server/mocks'; -import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server'; -import { LegacyAPI } from '../../new_platform/plugin'; -import { SpacesService } from '../../new_platform/spaces_service'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; +} from '../../../../../../src/core/server/mocks'; +import * as kbnTestServer from '../../../../../../src/test_utils/kbn_server'; +import { LegacyAPI, PluginsSetup } from '../../plugin'; +import { SpacesService } from '../../spaces_service'; import { SpacesAuditLogger } from '../audit_logger'; -import { SecurityPlugin } from '../../../../security'; import { convertSavedObjectToSpace } from '../../routes/lib'; -import { XPackMainPlugin } from '../../../../xpack_main/xpack_main'; -import { Feature } from '../../../../../../plugins/features/server'; import { initSpacesOnPostAuthRequestInterceptor } from './on_post_auth_interceptor'; +import { Feature } from '../../../../features/server'; +import { OptionalPlugin } from '../../../../../legacy/server/lib/optional_plugin'; +import { SecurityPlugin } from '../../../../../legacy/plugins/security'; +import { spacesConfig } from '../__fixtures__'; describe('onPostAuthInterceptor', () => { let root: ReturnType; - const defaultRoute = '/app/kibana'; const headers = { authorization: `Basic ${Buffer.from( `${kibanaTestUser.username}:${kibanaTestUser.password}` @@ -116,7 +115,7 @@ describe('onPostAuthInterceptor', () => { .asLoggerFactory() .get('xpack', 'spaces'); - const xpackMainPlugin = { + const featuresPlugin = { getFeatures: () => [ { @@ -140,7 +139,7 @@ describe('onPostAuthInterceptor', () => { app: ['kibana'], }, ] as Feature[], - } as XPackMainPlugin; + } as PluginsSetup['features']; const savedObjectsService = { SavedObjectsClient: { @@ -163,10 +162,6 @@ describe('onPostAuthInterceptor', () => { }; const legacyAPI = { - legacyConfig: { - serverDefaultRoute: defaultRoute, - serverBasePath: '', - }, savedObjects: (savedObjectsService as unknown) as SavedObjectsLegacyService, } as LegacyAPI; @@ -175,9 +170,9 @@ describe('onPostAuthInterceptor', () => { const spacesService = await service.setup({ http: (http as unknown) as CoreSetup['http'], elasticsearch: elasticsearchServiceMock.createSetupContract(), - security: {} as OptionalPlugin, + getSecurity: () => ({} as OptionalPlugin), getSpacesAuditLogger: () => ({} as SpacesAuditLogger), - config$: Rx.of({ maxSpaces: 1000 }), + config$: Rx.of(spacesConfig), }); spacesService.scopedClient = jest.fn().mockResolvedValue({ @@ -211,7 +206,7 @@ describe('onPostAuthInterceptor', () => { getLegacyAPI: () => legacyAPI, http: (http as unknown) as CoreSetup['http'], log: loggingMock, - xpackMain: xpackMainPlugin, + features: featuresPlugin, spacesService, }); diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts similarity index 85% rename from x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts rename to x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts index e02677d94a8da..4674f3641084a 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts @@ -6,9 +6,8 @@ import { Logger, CoreSetup } from 'src/core/server'; import { Space } from '../../../common/model/space'; import { wrapError } from '../errors'; -import { XPackMainPlugin } from '../../../../xpack_main/xpack_main'; -import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_service'; -import { LegacyAPI } from '../../new_platform/plugin'; +import { SpacesServiceSetup } from '../../spaces_service/spaces_service'; +import { LegacyAPI, PluginsSetup } from '../../plugin'; import { getSpaceSelectorUrl } from '../get_space_selector_url'; import { DEFAULT_SPACE_ID, ENTER_SPACE_PATH } from '../../../common/constants'; import { addSpaceIdToPath } from '../../../common'; @@ -16,21 +15,21 @@ import { addSpaceIdToPath } from '../../../common'; export interface OnPostAuthInterceptorDeps { getLegacyAPI(): LegacyAPI; http: CoreSetup['http']; - xpackMain: XPackMainPlugin; + features: PluginsSetup['features']; spacesService: SpacesServiceSetup; log: Logger; } export function initSpacesOnPostAuthRequestInterceptor({ - xpackMain, + features, getLegacyAPI, spacesService, log, http, }: OnPostAuthInterceptorDeps) { - const { serverBasePath } = getLegacyAPI().legacyConfig; - http.registerOnPostAuth(async (request, response, toolkit) => { + const serverBasePath = http.basePath.serverBasePath; + const path = request.url.pathname!; const spaceId = spacesService.getSpaceId(request); @@ -66,12 +65,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ }); } } catch (error) { - const wrappedError = wrapError(error); - return response.customError({ - body: wrappedError, - headers: wrappedError.output.headers, - statusCode: wrappedError.output.statusCode, - }); + return response.customError(wrapError(error)); } } else if (isRequestingSpaceRoot) { const destination = addSpaceIdToPath(serverBasePath, spaceId, ENTER_SPACE_PATH); @@ -89,7 +83,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ } catch (error) { const wrappedError = wrapError(error); - const statusCode = wrappedError.output.statusCode; + const statusCode = wrappedError.statusCode; // If user is not authorized, or the space cannot be found, allow them to select another space // by redirecting to the space selector. @@ -106,11 +100,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ }); } else { log.error(`Unable to navigate to space "${spaceId}". ${error}`); - return response.customError({ - body: wrappedError, - headers: wrappedError.output.headers, - statusCode: wrappedError.output.statusCode, - }); + return response.customError(wrappedError); } } @@ -120,7 +110,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ if (appId !== 'kibana' && space && space.disabledFeatures.length > 0) { log.debug(`Verifying application is available: "${appId}"`); - const allFeatures = xpackMain.getFeatures(); + const allFeatures = features.getFeatures(); const isRegisteredApp = allFeatures.some(feature => feature.app.includes(appId)); if (isRegisteredApp) { diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts similarity index 96% rename from x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts rename to x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts index fcfa80838efba..d6ff4a20052e4 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts @@ -13,10 +13,10 @@ import { CoreSetup, IBasePath, IRouter, -} from '../../../../../../../src/core/server'; +} from '../../../../../../src/core/server'; -import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server'; -import { LegacyAPI } from '../../new_platform/plugin'; +import * as kbnTestServer from '../../../../../../src/test_utils/kbn_server'; +import { LegacyAPI } from '../../plugin'; describe('onRequestInterceptor', () => { let root: ReturnType; @@ -109,9 +109,7 @@ describe('onRequestInterceptor', () => { initSpacesOnRequestInterceptor({ getLegacyAPI: () => ({ - legacyConfig: { - serverBasePath: opts.basePath, - }, + legacyConfig: {}, } as LegacyAPI), http: (http as unknown) as CoreSetup['http'], }); diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts similarity index 93% rename from x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts rename to x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts index 114cc9bf86d46..22d704c1b7e13 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts @@ -12,7 +12,7 @@ import { import { format } from 'url'; import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { modifyUrl } from '../utils/url'; -import { LegacyAPI } from '../../new_platform/plugin'; +import { LegacyAPI } from '../../plugin'; import { getSpaceIdFromPath } from '../../../common'; export interface OnRequestInterceptorDeps { @@ -25,7 +25,7 @@ export function initSpacesOnRequestInterceptor({ getLegacyAPI, http }: OnRequest response: LifecycleResponseFactory, toolkit: OnPreAuthToolkit ) { - const { serverBasePath } = getLegacyAPI().legacyConfig; + const serverBasePath = http.basePath.serverBasePath; const path = request.url.pathname; // If navigating within the context of a space, then we store the Space's URL Context on the request, diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap b/x-pack/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap rename to x-pack/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts similarity index 88% rename from x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts rename to x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts index 466c3237fd7db..aa61af07c268e 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts @@ -6,7 +6,7 @@ import { SavedObjectsClientWrapperFactory } from 'src/core/server'; import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; -import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_service'; +import { SpacesServiceSetup } from '../../spaces_service/spaces_service'; export function spacesSavedObjectsClientWrapperFactory( spacesService: SpacesServiceSetup, diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts similarity index 99% rename from x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts rename to x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts index 4a5796b2e4ea2..7e1c4ff211a6f 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts @@ -6,7 +6,7 @@ import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; -import { spacesServiceMock } from '../../new_platform/spaces_service/spaces_service.mock'; +import { spacesServiceMock } from '../../spaces_service/spaces_service.mock'; const types = ['foo', 'bar', 'space']; diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts similarity index 98% rename from x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts rename to x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts index d47a22e8d4545..2c5e4d0998b51 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts @@ -14,7 +14,7 @@ import { SavedObjectsFindOptions, SavedObjectsUpdateOptions, } from 'src/core/server'; -import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_service'; +import { SpacesServiceSetup } from '../../spaces_service/spaces_service'; import { spaceIdToNamespace } from '../utils/namespace'; interface SpacesSavedObjectsClientOptions { diff --git a/x-pack/plugins/spaces/server/lib/space_schema.test.ts b/x-pack/plugins/spaces/server/lib/space_schema.test.ts new file mode 100644 index 0000000000000..92ccb5401893a --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/space_schema.test.ts @@ -0,0 +1,231 @@ +/* + * 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 { spaceSchema } from './space_schema'; + +const defaultProperties = { + id: 'foo', + name: 'foo', +}; + +describe('#id', () => { + test('is required', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + id: undefined, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[id]: expected value of type [string] but got [undefined]"` + ); + }); + + test('allows lowercase a-z, 0-9, "_" and "-"', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + id: 'abcdefghijklmnopqrstuvwxyz0123456789_-', + }) + ).not.toThrowError(); + }); + + test(`doesn't allow uppercase`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + id: 'Foo', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[id]: must be lower case, a-z, 0-9, '_', and '-' are allowed"` + ); + }); + + test(`doesn't allow an empty string`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + id: '', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[id]: must be lower case, a-z, 0-9, '_', and '-' are allowed"` + ); + }); + + ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '+', ',', '.', '/', '?'].forEach( + invalidCharacter => { + test(`doesn't allow ${invalidCharacter}`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + id: `foo-${invalidCharacter}`, + }) + ).toThrowError(); + }); + } + ); +}); + +describe('#disabledFeatures', () => { + test('is optional', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + disabledFeatures: undefined, + }) + ).not.toThrowError(); + }); + + test('defaults to an empty array', () => { + const result = spaceSchema.validate({ + ...defaultProperties, + disabledFeatures: undefined, + }); + expect(result.disabledFeatures).toEqual([]); + }); + + test('must be an array if provided', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + disabledFeatures: 'foo', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[disabledFeatures]: expected value of type [array] but got [string]"` + ); + }); + + test('allows an array of strings', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + disabledFeatures: ['foo', 'bar'], + }) + ).not.toThrowError(); + }); + + test('does not allow an array containing non-string elements', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + disabledFeatures: ['foo', true], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[disabledFeatures.1]: expected value of type [string] but got [boolean]"` + ); + }); +}); + +describe('#color', () => { + test('is optional', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: undefined, + }) + ).not.toThrowError(); + }); + + test(`doesn't allow an empty string`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[color]: must be a 6 digit hex color, starting with a #"` + ); + }); + + test(`allows lower case hex color code`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '#aabbcc', + }) + ).not.toThrowError(); + }); + + test(`allows upper case hex color code`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '#AABBCC', + }) + ).not.toThrowError(); + }); + + test(`allows numeric hex color code`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '#123456', + }) + ).not.toThrowError(); + }); + + test(`must start with a hash`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '123456', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[color]: must be a 6 digit hex color, starting with a #"` + ); + }); + + test(`cannot exceed 6 digits following the hash`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '1234567', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[color]: must be a 6 digit hex color, starting with a #"` + ); + }); + + test(`cannot be fewer than 6 digits following the hash`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '12345', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[color]: must be a 6 digit hex color, starting with a #"` + ); + }); +}); + +describe('#imageUrl', () => { + test('is optional', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + imageUrl: undefined, + }) + ).not.toThrowError(); + }); + + test(`must start with data:image`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + imageUrl: 'notValid', + }) + ).toThrowErrorMatchingInlineSnapshot(`"[imageUrl]: must start with 'data:image'"`); + }); + + test(`checking that a valid image is accepted as imageUrl`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + imageUrl: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTnU1rJkAAAB3klEQVRYR+2WzUrDQBCARzwqehE8ir1WPfgqRRA1bePBXgpe/MGCB9/Aiw+j+ASCB6kotklaEwW1F0WwNSaps9lV69awGzBpDzt8pJP9mXxsmk3ABH2oUEIilJAIJSRCCYlQQiKUkIh4QgY5agZodVjBowFrBktWQzDBU2ykiYaDuQpCYgnl3QunGzM6Z6YF+b5SkcgK1UH/aLbYReQiYL9d9/o+XFop5IU0Vl4uapAzoXC3eEBPw9vH1/wT6Vs2otPSkoH/IZzlzO/TU2vgQm8nl69Hp0H7nZ4OXogLJSSKBIUC3w88n+Ueyfv56fVZnqCQNVnCHbLrkV0Gd2d+GNkglsk438dhaTxloZDutV4wb06Vf40JcWZ2sMttPpE8NaHGeBnzIAhwPXqHseVB11EyLD0hxLUeaYud2a3B0g3k7GyFtrhX7F2RqhC+yV3jgTb2Rqdqf7/kUxYiWBOlTtXxfPJEtc8b5thGb+8AhL4ohnCNqQjZ2T2+K5rnw2M6KwEhKNDSGM3pTdxjhDgLbHkw/v/zw4AiPuSsfMzAiTidKxiF/ArpFqyzK8SMOlkwvloUMYRCtNvZLWeuIomd2Za/WZS4QomjhEQoIRFKSIQSEqGERAyfEH4YDBFQ/ARU6BiBxCAIQQAAAABJRU5ErkJggg==', + }) + ).not.toThrowError(); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/space_schema.ts b/x-pack/plugins/spaces/server/lib/space_schema.ts new file mode 100644 index 0000000000000..18d9478576e52 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/space_schema.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { MAX_SPACE_INITIALS } from '../../common'; + +export const SPACE_ID_REGEX = /^[a-z0-9_\-]+$/; + +export const spaceSchema = schema.object({ + id: schema.string({ + validate: value => { + if (!SPACE_ID_REGEX.test(value)) { + return `must be lower case, a-z, 0-9, '_', and '-' are allowed`; + } + }, + }), + name: schema.string({ minLength: 1 }), + description: schema.maybe(schema.string()), + initials: schema.maybe(schema.string({ maxLength: MAX_SPACE_INITIALS })), + color: schema.maybe( + schema.string({ + validate: value => { + if (!/^#[a-zA-Z0-9]{6}$/.test(value)) { + return `must be a 6 digit hex color, starting with a #`; + } + }, + }) + ), + disabledFeatures: schema.arrayOf(schema.string(), { defaultValue: [] }), + _reserved: schema.maybe(schema.boolean()), + imageUrl: schema.maybe( + schema.string({ + validate: value => { + if (value !== '' && !/^data:image.*$/.test(value)) { + return `must start with 'data:image'`; + } + }, + }) + ), +}); diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap rename to x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/index.ts b/x-pack/plugins/spaces/server/lib/spaces_client/index.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_client/index.ts rename to x-pack/plugins/spaces/server/lib/spaces_client/index.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts similarity index 96% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts rename to x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts index d773cd86ef688..10f6292abf319 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts @@ -29,7 +29,7 @@ const createSpacesClientMock = () => create: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), update: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), delete: jest.fn(), - } as unknown) as SpacesClient); + } as unknown) as jest.Mocked); export const spacesClientMock = { create: createSpacesClientMock, diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts similarity index 98% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts rename to x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 78ad10bbd9164..e62a3a0efa601 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -5,9 +5,9 @@ */ import { SpacesClient } from './spaces_client'; -import { AuthorizationService } from '../../../../security/server/lib/authorization/service'; -import { actionsFactory } from '../../../../security/server/lib/authorization/actions'; -import { SpacesConfigType, config } from '../../new_platform/config'; +import { AuthorizationService } from '../../../../../legacy/plugins/security/server/lib/authorization/service'; +import { actionsFactory } from '../../../../../legacy/plugins/security/server/lib/authorization/actions'; +import { ConfigType, ConfigSchema } from '../../config'; import { GetSpacePurpose } from '../../../common/model/types'; const createMockAuditLogger = () => { @@ -69,8 +69,8 @@ const createMockAuthorization = () => { }; }; -const createMockConfig = (mockConfig: SpacesConfigType = { maxSpaces: 1000 }) => { - return config.schema.validate(mockConfig); +const createMockConfig = (mockConfig: ConfigType = { maxSpaces: 1000, enabled: true }) => { + return ConfigSchema.validate(mockConfig); }; describe('#getAll', () => { @@ -123,6 +123,7 @@ describe('#getAll', () => { const maxSpaces = 1234; const mockConfig = createMockConfig({ maxSpaces: 1234, + enabled: true, }); const client = new SpacesClient( @@ -162,6 +163,7 @@ describe('#getAll', () => { const maxSpaces = 1234; const mockConfig = createMockConfig({ maxSpaces: 1234, + enabled: true, }); const request = Symbol() as any; @@ -287,6 +289,7 @@ describe('#getAll', () => { const maxSpaces = 1234; const mockConfig = createMockConfig({ maxSpaces: 1234, + enabled: true, }); const mockInternalRepository = { find: jest.fn().mockReturnValue({ @@ -355,6 +358,7 @@ describe('#getAll', () => { const maxSpaces = 1234; const mockConfig = createMockConfig({ maxSpaces: 1234, + enabled: true, }); const request = Symbol() as any; @@ -725,6 +729,7 @@ describe('#create', () => { }; const mockConfig = createMockConfig({ maxSpaces, + enabled: true, }); const request = Symbol() as any; @@ -766,6 +771,7 @@ describe('#create', () => { }; const mockConfig = createMockConfig({ maxSpaces, + enabled: true, }); const request = Symbol() as any; @@ -807,6 +813,7 @@ describe('#create', () => { }; const mockConfig = createMockConfig({ maxSpaces, + enabled: true, }); const request = Symbol() as any; @@ -850,6 +857,7 @@ describe('#create', () => { }; const mockConfig = createMockConfig({ maxSpaces, + enabled: true, }); const request = Symbol() as any; @@ -931,6 +939,7 @@ describe('#create', () => { }; const mockConfig = createMockConfig({ maxSpaces, + enabled: true, }); const request = Symbol() as any; @@ -983,6 +992,7 @@ describe('#create', () => { }; const mockConfig = createMockConfig({ maxSpaces, + enabled: true, }); const request = Symbol() as any; diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts similarity index 97% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.ts rename to x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index 6d30084d0dc86..052534879e678 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -7,11 +7,11 @@ import Boom from 'boom'; import { omit } from 'lodash'; import { Legacy } from 'kibana'; import { KibanaRequest } from 'src/core/server'; -import { AuthorizationService } from '../../../../security/server/lib/authorization/service'; +import { AuthorizationService } from '../../../../../legacy/plugins/security/server/lib/authorization/service'; import { isReservedSpace } from '../../../common/is_reserved_space'; import { Space } from '../../../common/model/space'; import { SpacesAuditLogger } from '../audit_logger'; -import { SpacesConfigType } from '../../new_platform/config'; +import { ConfigType } from '../../config'; import { GetSpacePurpose } from '../../../common/model/types'; type SpacesClientRequestFacade = Legacy.Request | KibanaRequest; @@ -33,7 +33,7 @@ export class SpacesClient { private readonly debugLogger: (message: string) => void, private readonly authorization: AuthorizationService | null, private readonly callWithRequestSavedObjectRepository: any, - private readonly config: SpacesConfigType, + private readonly config: ConfigType, private readonly internalSavedObjectRepository: any, private readonly request: SpacesClientRequestFacade ) {} diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts similarity index 81% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts rename to x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts index 725b4cdc6bbad..4fbc4df03d00e 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts @@ -7,13 +7,14 @@ import * as Rx from 'rxjs'; import { DEFAULT_SPACE_ID } from '../../common/constants'; import { createSpacesTutorialContextFactory } from './spaces_tutorial_context_factory'; -import { SpacesService } from '../new_platform/spaces_service'; +import { SpacesService } from '../spaces_service'; import { SavedObjectsLegacyService } from 'src/core/server'; import { SpacesAuditLogger } from './audit_logger'; -import { elasticsearchServiceMock, coreMock } from '../../../../../../src/core/server/mocks'; -import { spacesServiceMock } from '../new_platform/spaces_service/spaces_service.mock'; -import { createOptionalPlugin } from '../../../../server/lib/optional_plugin'; -import { LegacyAPI } from '../new_platform/plugin'; +import { elasticsearchServiceMock, coreMock } from '../../../../../src/core/server/mocks'; +import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; +import { createOptionalPlugin } from '../../../../legacy/server/lib/optional_plugin'; +import { LegacyAPI } from '../plugin'; +import { spacesConfig } from './__fixtures__'; const log = { log: jest.fn(), @@ -26,9 +27,7 @@ const log = { }; const legacyAPI: LegacyAPI = { - legacyConfig: { - serverBasePath: '/foo', - }, + legacyConfig: {}, savedObjects: {} as SavedObjectsLegacyService, } as LegacyAPI; @@ -56,9 +55,10 @@ describe('createSpacesTutorialContextFactory', () => { const spacesService = await service.setup({ http: coreMock.createSetup().http, elasticsearch: elasticsearchServiceMock.createSetupContract(), - security: createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), getSpacesAuditLogger: () => ({} as SpacesAuditLogger), - config$: Rx.of({ maxSpaces: 1000 }), + config$: Rx.of(spacesConfig), }); const contextFactory = createSpacesTutorialContextFactory(spacesService); diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts similarity index 86% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts rename to x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts index 770294840f1c6..f89681b709949 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SpacesServiceSetup } from '../new_platform/spaces_service/spaces_service'; +import { SpacesServiceSetup } from '../spaces_service/spaces_service'; export function createSpacesTutorialContextFactory(spacesService: SpacesServiceSetup) { return function spacesTutorialContextFactory(request: any) { diff --git a/x-pack/legacy/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts b/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts similarity index 98% rename from x-pack/legacy/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts rename to x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts index 18de89e33cb9a..f6133f5c03c6b 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts +++ b/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts @@ -5,7 +5,7 @@ */ import { UICapabilities } from 'ui/capabilities'; -import { Feature } from '../../../../../plugins/features/server'; +import { Feature } from '../../../../plugins/features/server'; import { Space } from '../../common/model/space'; import { toggleUICapabilities } from './toggle_ui_capabilities'; diff --git a/x-pack/legacy/plugins/spaces/server/lib/toggle_ui_capabilities.ts b/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.ts similarity index 97% rename from x-pack/legacy/plugins/spaces/server/lib/toggle_ui_capabilities.ts rename to x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.ts index c80ebdf88ef46..63f52ac379300 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/toggle_ui_capabilities.ts +++ b/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.ts @@ -5,7 +5,7 @@ */ import _ from 'lodash'; import { UICapabilities } from 'ui/capabilities'; -import { Feature } from '../../../../../plugins/features/server'; +import { Feature } from '../../../../plugins/features/server'; import { Space } from '../../common/model/space'; export function toggleUICapabilities( diff --git a/x-pack/legacy/plugins/spaces/server/lib/utils/namespace.test.ts b/x-pack/plugins/spaces/server/lib/utils/namespace.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/utils/namespace.test.ts rename to x-pack/plugins/spaces/server/lib/utils/namespace.test.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/utils/namespace.ts b/x-pack/plugins/spaces/server/lib/utils/namespace.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/utils/namespace.ts rename to x-pack/plugins/spaces/server/lib/utils/namespace.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/utils/url.test.ts b/x-pack/plugins/spaces/server/lib/utils/url.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/utils/url.test.ts rename to x-pack/plugins/spaces/server/lib/utils/url.test.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/utils/url.ts b/x-pack/plugins/spaces/server/lib/utils/url.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/utils/url.ts rename to x-pack/plugins/spaces/server/lib/utils/url.ts diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts similarity index 57% rename from x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts rename to x-pack/plugins/spaces/server/plugin.ts index ed11e6da317fa..4b071baaa7e2c 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -5,30 +5,33 @@ */ import { Observable } from 'rxjs'; -import { SavedObjectsLegacyService, CoreSetup } from 'src/core/server'; -import { Logger, PluginInitializerContext } from 'src/core/server'; +import { take } from 'rxjs/operators'; import { CapabilitiesModifier } from 'src/legacy/server/capabilities'; -import { Legacy } from 'kibana'; -import { OptionalPlugin } from '../../../../server/lib/optional_plugin'; -import { XPackMainPlugin } from '../../../xpack_main/xpack_main'; -import { createDefaultSpace } from '../lib/create_default_space'; +import { + SavedObjectsLegacyService, + CoreSetup, + KibanaRequest, + Logger, + PluginInitializerContext, +} from '../../../../src/core/server'; +import { SecurityPlugin } from '../../../legacy/plugins/security'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { OptionalPlugin } from '../../../legacy/server/lib/optional_plugin'; +import { XPackMainPlugin } from '../../../legacy/plugins/xpack_main/xpack_main'; +import { createDefaultSpace } from './lib/create_default_space'; // @ts-ignore import { AuditLogger } from '../../../../server/lib/audit_logger'; -// @ts-ignore -import { watchStatusAndLicenseToInitialize } from '../../../../server/lib/watch_status_and_license_to_initialize'; -import { checkLicense } from '../lib/check_license'; -import { spacesSavedObjectsClientWrapperFactory } from '../lib/saved_objects_client/saved_objects_client_wrapper_factory'; -import { SpacesAuditLogger } from '../lib/audit_logger'; -import { createSpacesTutorialContextFactory } from '../lib/spaces_tutorial_context_factory'; -import { initExternalSpacesApi } from '../routes/api/external'; -import { getSpacesUsageCollector } from '../lib/get_spaces_usage_collector'; +import { spacesSavedObjectsClientWrapperFactory } from './lib/saved_objects_client/saved_objects_client_wrapper_factory'; +import { SpacesAuditLogger } from './lib/audit_logger'; +import { createSpacesTutorialContextFactory } from './lib/spaces_tutorial_context_factory'; +import { getSpacesUsageCollector } from './lib/get_spaces_usage_collector'; import { SpacesService } from './spaces_service'; -import { SecurityPlugin } from '../../../security'; import { SpacesServiceSetup } from './spaces_service/spaces_service'; -import { SpacesConfigType } from './config'; -import { getActiveSpace } from '../lib/get_active_space'; -import { toggleUICapabilities } from '../lib/toggle_ui_capabilities'; -import { initSpacesRequestInterceptors } from '../lib/request_interceptors'; +import { ConfigType } from './config'; +import { toggleUICapabilities } from './lib/toggle_ui_capabilities'; +import { initSpacesRequestInterceptors } from './lib/request_interceptors'; +import { initExternalSpacesApi } from './routes/api/external'; /** * Describes a set of APIs that is available in the legacy platform only and required by this plugin @@ -52,30 +55,33 @@ export interface LegacyAPI { }; legacyConfig: { kibanaIndex: string; - serverBasePath: string; - serverDefaultRoute: string; }; - router: Legacy.Server['route']; -} - -export interface PluginsSetup { + xpackMain: XPackMainPlugin; // TODO: Spaces has a circular dependency with Security right now. // Security is not yet available when init runs, so this is wrapped in an optional plugin for the time being. security: OptionalPlugin; - xpackMain: XPackMainPlugin; - // TODO: this is temporary for `watchLicenseAndStatusToInitialize` - spaces: any; +} + +export interface PluginsSetup { + features: FeaturesPluginSetup; + licensing: LicensingPluginSetup; } export interface SpacesPluginSetup { spacesService: SpacesServiceSetup; - registerLegacyAPI: (legacyAPI: LegacyAPI) => void; + __legacyCompat: { + registerLegacyAPI: (legacyAPI: LegacyAPI) => void; + // TODO: We currently need the legacy plugin to inform this plugin when it is safe to create the default space. + // The NP does not have the equivilent ES connection/health/comapt checks that the legacy world does. + // See: https://github.com/elastic/kibana/issues/43456 + createDefaultSpace: () => Promise; + }; } export class Plugin { private readonly pluginId = 'spaces'; - private readonly config$: Observable; + private readonly config$: Observable; private readonly log: Logger; @@ -98,99 +104,91 @@ export class Plugin { }; constructor(initializerContext: PluginInitializerContext) { - this.config$ = initializerContext.config.create(); + this.config$ = initializerContext.config.create(); this.log = initializerContext.logger.get(); } - public async setup(core: CoreSetup, plugins: PluginsSetup): Promise { - const xpackMainPlugin: XPackMainPlugin = plugins.xpackMain; - watchStatusAndLicenseToInitialize(xpackMainPlugin, plugins.spaces, async () => { - await createDefaultSpace({ - elasticsearch: core.elasticsearch, - savedObjects: this.getLegacyAPI().savedObjects, - }); - }); - - // Register a function that is called whenever the xpack info changes, - // to re-compute the license check results for this plugin. - xpackMainPlugin.info.feature(this.pluginId).registerLicenseCheckResultsGenerator(checkLicense); + public async start() {} + public async setup(core: CoreSetup, plugins: PluginsSetup): Promise { const service = new SpacesService(this.log, this.getLegacyAPI); const spacesService = await service.setup({ http: core.http, elasticsearch: core.elasticsearch, - security: plugins.security, + getSecurity: () => this.getLegacyAPI().security, getSpacesAuditLogger: this.getSpacesAuditLogger, config$: this.config$, }); + const externalRouter = core.http.createRouter(); + initExternalSpacesApi({ + externalRouter, + log: this.log, + getSavedObjects: () => this.getLegacyAPI().savedObjects, + spacesService, + }); + + initSpacesRequestInterceptors({ + http: core.http, + log: this.log, + getLegacyAPI: this.getLegacyAPI, + spacesService, + features: plugins.features, + }); + return { spacesService, - registerLegacyAPI: (legacyAPI: LegacyAPI) => { - this.legacyAPI = legacyAPI; - this.setupLegacyComponents(core, spacesService, plugins.xpackMain); + __legacyCompat: { + registerLegacyAPI: (legacyAPI: LegacyAPI) => { + this.legacyAPI = legacyAPI; + this.setupLegacyComponents(spacesService, plugins.features, plugins.licensing); + }, + createDefaultSpace: async () => { + const esClient = await core.elasticsearch.adminClient$.pipe(take(1)).toPromise(); + return createDefaultSpace({ + esClient, + savedObjects: this.getLegacyAPI().savedObjects, + }); + }, }, }; } + public stop() {} + private setupLegacyComponents( - core: CoreSetup, spacesService: SpacesServiceSetup, - xpackMainPlugin: XPackMainPlugin + featuresSetup: FeaturesPluginSetup, + licensingSetup: LicensingPluginSetup ) { const legacyAPI = this.getLegacyAPI(); - const { addScopedSavedObjectsClientWrapperFactory, types } = legacyAPI.savedObjects; addScopedSavedObjectsClientWrapperFactory( Number.MIN_SAFE_INTEGER, 'spaces', spacesSavedObjectsClientWrapperFactory(spacesService, types) ); - legacyAPI.tutorial.addScopedTutorialContextFactory( createSpacesTutorialContextFactory(spacesService) ); - legacyAPI.capabilities.registerCapabilitiesModifier(async (request, uiCapabilities) => { - const spacesClient = await spacesService.scopedClient(request); try { - const activeSpace = await getActiveSpace( - spacesClient, - core.http.basePath.get(request), - legacyAPI.legacyConfig.serverBasePath - ); - - const features = xpackMainPlugin.getFeatures(); + const activeSpace = await spacesService.getActiveSpace(KibanaRequest.from(request)); + const features = featuresSetup.getFeatures(); return toggleUICapabilities(features, uiCapabilities, activeSpace); } catch (e) { return uiCapabilities; } }); - // Register a function with server to manage the collection of usage stats legacyAPI.usage.collectorSet.register( getSpacesUsageCollector({ kibanaIndex: legacyAPI.legacyConfig.kibanaIndex, usage: legacyAPI.usage, - xpackMain: xpackMainPlugin, + features: featuresSetup, + licensing: licensingSetup, }) ); - - initExternalSpacesApi({ - legacyRouter: legacyAPI.router, - log: this.log, - savedObjects: legacyAPI.savedObjects, - spacesService, - xpackMain: xpackMainPlugin, - }); - - initSpacesRequestInterceptors({ - http: core.http, - log: this.log, - getLegacyAPI: this.getLegacyAPI, - spacesService, - xpackMain: xpackMainPlugin, - }); } } diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts new file mode 100644 index 0000000000000..5f366871ba81e --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.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 { Readable } from 'stream'; +import { createPromiseFromStreams, createConcatStream } from 'src/legacy/utils/streams'; +import { SavedObjectsSchema, SavedObjectsLegacyService } from 'src/core/server'; +import { LegacyAPI } from '../../../plugin'; +import { Space } from '../../../../common/model/space'; +import { createSpaces } from '.'; + +async function readStreamToCompletion(stream: Readable) { + return (await (createPromiseFromStreams([stream, createConcatStream([])]) as unknown)) as any[]; +} + +interface LegacyAPIOpts { + spaces?: Space[]; +} + +export const createLegacyAPI = ({ + spaces = createSpaces().map(s => ({ id: s.id, ...s.attributes })), +}: LegacyAPIOpts = {}) => { + const mockSavedObjectsClientContract = { + get: jest.fn((type, id) => { + const result = spaces.filter(s => s.id === id); + if (!result.length) { + throw new Error(`not found: [${type}:${id}]`); + } + return result[0]; + }), + find: jest.fn(() => { + return { + total: spaces.length, + saved_objects: spaces, + }; + }), + create: jest.fn((type, attributes, { id }) => { + if (spaces.find(s => s.id === id)) { + throw new Error('conflict'); + } + return {}; + }), + update: jest.fn((type, id) => { + if (!spaces.find(s => s.id === id)) { + throw new Error('not found: during update'); + } + return {}; + }), + delete: jest.fn((type: string, id: string) => { + return {}; + }), + deleteByNamespace: jest.fn(), + }; + + const savedObjectsService = ({ + types: ['visualization', 'dashboard', 'index-pattern', 'globalType'], + schema: new SavedObjectsSchema({ + space: { + isNamespaceAgnostic: true, + hidden: true, + }, + globalType: { + isNamespaceAgnostic: true, + }, + }), + getScopedSavedObjectsClient: jest.fn().mockResolvedValue(mockSavedObjectsClientContract), + importExport: { + objectLimit: 10000, + getSortedObjectsForExport: jest.fn().mockResolvedValue( + new Readable({ + objectMode: true, + read() { + this.push(null); + }, + }) + ), + importSavedObjects: jest.fn().mockImplementation(async (opts: Record) => { + const objectsToImport: any[] = await readStreamToCompletion(opts.readStream); + return { + success: true, + successCount: objectsToImport.length, + }; + }), + resolveImportErrors: jest.fn().mockImplementation(async (opts: Record) => { + const objectsToImport: any[] = await readStreamToCompletion(opts.readStream); + return { + success: true, + successCount: objectsToImport.length, + }; + }), + }, + SavedObjectsClient: { + errors: { + isNotFoundError: jest.fn((e: any) => e.message.startsWith('not found:')), + isConflictError: jest.fn((e: any) => e.message.startsWith('conflict')), + }, + }, + } as unknown) as jest.Mocked; + + const legacyAPI: jest.Mocked = { + legacyConfig: { + kibanaIndex: '', + }, + auditLogger: {} as any, + capabilities: {} as any, + security: {} as any, + tutorial: {} as any, + usage: {} as any, + xpackMain: {} as any, + savedObjects: savedObjectsService, + }; + + return legacyAPI; +}; diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts new file mode 100644 index 0000000000000..1548a88e554e3 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server'; + +export const createMockSavedObjectsRepository = (spaces: any[] = []) => { + const mockSavedObjectsClientContract = ({ + get: jest.fn((type, id) => { + const result = spaces.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + }), + find: jest.fn(() => { + return { + total: spaces.length, + saved_objects: spaces, + }; + }), + create: jest.fn((type, attributes, { id }) => { + if (spaces.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.decorateConflictError(new Error(), 'space conflict'); + } + return {}; + }), + update: jest.fn((type, id) => { + if (!spaces.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return {}; + }), + delete: jest.fn((type: string, id: string) => { + return {}; + }), + deleteByNamespace: jest.fn(), + } as unknown) as jest.Mocked; + + return mockSavedObjectsClientContract; +}; diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts similarity index 86% rename from x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts rename to x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts index 85284e3fc3a1c..0e23054819ea5 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts @@ -10,18 +10,21 @@ export function createSpaces() { id: 'a-space', attributes: { name: 'a space', + disabledFeatures: [], }, }, { id: 'b-space', attributes: { name: 'b space', + disabledFeatures: [], }, }, { id: 'default', attributes: { name: 'Default Space', + disabledFeatures: [], _reserved: true, }, }, diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/index.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts similarity index 56% rename from x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/index.ts rename to x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts index 37fe32c80032e..1f5a5fe2cc91e 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts @@ -5,11 +5,6 @@ */ export { createSpaces } from './create_spaces'; -export { - createTestHandler, - TestConfig, - TestOptions, - TeardownFn, - RequestRunner, - RequestRunnerResult, -} from './create_test_handler'; +export { createLegacyAPI } from './create_legacy_api'; +export { createMockSavedObjectsRepository } from './create_mock_so_repository'; +export { mockRouteContext, mockRouteContextWithInvalidLicense } from './route_contexts'; diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts new file mode 100644 index 0000000000000..5bb811ef6be4c --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandlerContext } from 'src/core/server'; +import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; + +export const mockRouteContext = ({ + licensing: { + license: { + check: jest.fn().mockReturnValue({ + check: LICENSE_STATUS.Valid, + }), + }, + }, +} as unknown) as RequestHandlerContext; + +export const mockRouteContextWithInvalidLicense = ({ + licensing: { + license: { + check: jest.fn().mockReturnValue({ + check: LICENSE_STATUS.Invalid, + message: 'License is invalid for spaces', + }), + }, + }, +} as unknown) as RequestHandlerContext; 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 new file mode 100644 index 0000000000000..92744aa9f0fc5 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -0,0 +1,450 @@ +/* + * 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 Rx from 'rxjs'; +import { + createSpaces, + createLegacyAPI, + createMockSavedObjectsRepository, + mockRouteContext, + mockRouteContextWithInvalidLicense, +} from '../__fixtures__'; +import { CoreSetup, IRouter, kibanaResponseFactory } from 'src/core/server'; +import { + loggingServiceMock, + elasticsearchServiceMock, + httpServiceMock, + httpServerMock, +} from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initCopyToSpacesApi } from './copy_to_space'; +import { ObjectType } from '@kbn/config-schema'; +import { RouteSchemas } from 'src/core/server/http/router/route'; +import { spacesConfig } from '../../../lib/__fixtures__'; + +describe('copy to space', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + + const legacyAPI = createLegacyAPI({ spaces }); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const log = loggingServiceMock.create().get('spaces'); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (httpService as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of(spacesConfig), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + spacesConfig, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initCopyToSpacesApi({ + externalRouter: router, + getSavedObjects: () => legacyAPI.savedObjects, + log, + spacesService, + }); + + const [ + [ctsRouteDefinition, ctsRouteHandler], + [resolveRouteDefinition, resolveRouteHandler], + ] = router.post.mock.calls; + + return { + copyToSpace: { + routeValidation: ctsRouteDefinition.validate as RouteSchemas< + ObjectType, + ObjectType, + ObjectType + >, + routeHandler: ctsRouteHandler, + }, + resolveConflicts: { + routeValidation: resolveRouteDefinition.validate as RouteSchemas< + ObjectType, + ObjectType, + ObjectType + >, + routeHandler: resolveRouteHandler, + }, + savedObjectsRepositoryMock, + legacyAPI, + }; + }; + + describe('POST /api/spaces/_copy_saved_objects', () => { + it(`returns http/403 when the license is invalid`, async () => { + const { copyToSpace } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'post', + }); + + const response = await copyToSpace.routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { + const payload = { + spaces: ['a-space'], + objects: [], + }; + + const { copyToSpace, legacyAPI } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + await copyToSpace.routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(legacyAPI.savedObjects.getScopedSavedObjectsClient).toHaveBeenCalledWith( + expect.any(Object), + { + excludedWrappers: ['spaces'], + } + ); + }); + + it(`requires space IDs to be unique`, async () => { + const payload = { + spaces: ['a-space', 'a-space'], + objects: [], + }; + + const { copyToSpace } = await setup(); + + expect(() => + copyToSpace.routeValidation.body!.validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: duplicate space ids are not allowed"`); + }); + + it(`requires well-formed space IDS`, async () => { + const payload = { + spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'], + objects: [], + }; + + const { copyToSpace } = await setup(); + + expect(() => + copyToSpace.routeValidation.body!.validate(payload) + ).toThrowErrorMatchingInlineSnapshot( + `"[spaces.1]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed"` + ); + }); + + it(`requires objects to be unique`, async () => { + const payload = { + spaces: ['a-space'], + objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }], + }; + + const { copyToSpace } = await setup(); + + expect(() => + copyToSpace.routeValidation.body!.validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[objects]: duplicate objects are not allowed"`); + }); + + it('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { + const payload = { + spaces: ['a-space'], + objects: [{ type: 'globalType', id: 'bar' }, { type: 'visualization', id: 'bar' }], + }; + + const { copyToSpace, legacyAPI } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + const response = await copyToSpace.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status } = response; + + expect(status).toEqual(200); + expect(legacyAPI.savedObjects.importExport.importSavedObjects).toHaveBeenCalledTimes(1); + const [importCallOptions] = (legacyAPI.savedObjects.importExport + .importSavedObjects as any).mock.calls[0]; + + expect(importCallOptions).toMatchObject({ + namespace: 'a-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + }); + + it('copies to multiple spaces', async () => { + const payload = { + spaces: ['a-space', 'b-space'], + objects: [{ type: 'visualization', id: 'bar' }], + }; + + const { copyToSpace, legacyAPI } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + const response = await copyToSpace.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status } = response; + + expect(status).toEqual(200); + expect(legacyAPI.savedObjects.importExport.importSavedObjects).toHaveBeenCalledTimes(2); + const [firstImportCallOptions] = (legacyAPI.savedObjects.importExport + .importSavedObjects as any).mock.calls[0]; + + expect(firstImportCallOptions).toMatchObject({ + namespace: 'a-space', + }); + + const [secondImportCallOptions] = (legacyAPI.savedObjects.importExport + .importSavedObjects as any).mock.calls[1]; + + expect(secondImportCallOptions).toMatchObject({ + namespace: 'b-space', + }); + }); + }); + + describe('POST /api/spaces/_resolve_copy_saved_objects_errors', () => { + it(`returns http/403 when the license is invalid`, async () => { + const { resolveConflicts } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'post', + }); + + const response = await resolveConflicts.routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { + const payload = { + retries: { + ['a-space']: [ + { + type: 'visualization', + id: 'bar', + overwrite: true, + }, + ], + }, + objects: [{ type: 'visualization', id: 'bar' }], + }; + + const { resolveConflicts, legacyAPI } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + await resolveConflicts.routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(legacyAPI.savedObjects.getScopedSavedObjectsClient).toHaveBeenCalledWith( + expect.any(Object), + { + excludedWrappers: ['spaces'], + } + ); + }); + + it(`requires objects to be unique`, async () => { + const payload = { + retries: {}, + objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }], + }; + + const { resolveConflicts } = await setup(); + + expect(() => + resolveConflicts.routeValidation.body!.validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[objects]: duplicate objects are not allowed"`); + }); + + it(`requires well-formed space ids`, async () => { + const payload = { + retries: { + ['invalid-space-id!@#$%^&*()']: [ + { + type: 'foo', + id: 'bar', + overwrite: true, + }, + ], + }, + objects: [{ type: 'foo', id: 'bar' }], + }; + + const { resolveConflicts } = await setup(); + + expect(() => + resolveConflicts.routeValidation.body!.validate(payload) + ).toThrowErrorMatchingInlineSnapshot( + `"[key(\\"invalid-space-id!@#$%^&*()\\")]: Invalid space id: invalid-space-id!@#$%^&*()"` + ); + }); + + it('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { + const payload = { + retries: { + ['a-space']: [ + { + type: 'visualization', + id: 'bar', + overwrite: true, + }, + { + type: 'globalType', + id: 'bar', + overwrite: true, + }, + ], + }, + objects: [ + { + type: 'globalType', + id: 'bar', + }, + { type: 'visualization', id: 'bar' }, + ], + }; + + const { resolveConflicts, legacyAPI } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + const response = await resolveConflicts.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status } = response; + + expect(status).toEqual(200); + expect(legacyAPI.savedObjects.importExport.resolveImportErrors).toHaveBeenCalledTimes(1); + const [resolveImportErrorsCallOptions] = (legacyAPI.savedObjects.importExport + .resolveImportErrors as any).mock.calls[0]; + + expect(resolveImportErrorsCallOptions).toMatchObject({ + namespace: 'a-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + }); + + it('resolves conflicts for multiple spaces', async () => { + const payload = { + objects: [{ type: 'visualization', id: 'bar' }], + retries: { + ['a-space']: [ + { + type: 'visualization', + id: 'bar', + overwrite: true, + }, + ], + ['b-space']: [ + { + type: 'globalType', + id: 'bar', + overwrite: true, + }, + ], + }, + }; + + const { resolveConflicts, legacyAPI } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + const response = await resolveConflicts.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status } = response; + + expect(status).toEqual(200); + expect(legacyAPI.savedObjects.importExport.resolveImportErrors).toHaveBeenCalledTimes(2); + const [resolveImportErrorsFirstCallOptions] = (legacyAPI.savedObjects.importExport + .resolveImportErrors as any).mock.calls[0]; + + expect(resolveImportErrorsFirstCallOptions).toMatchObject({ + namespace: 'a-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + + const [resolveImportErrorsSecondCallOptions] = (legacyAPI.savedObjects.importExport + .resolveImportErrors as any).mock.calls[1]; + + expect(resolveImportErrorsSecondCallOptions).toMatchObject({ + namespace: 'b-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts new file mode 100644 index 0000000000000..040a0552c38be --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts @@ -0,0 +1,152 @@ +/* + * 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 _ from 'lodash'; +import { SavedObject } from 'src/core/server'; +import { + copySavedObjectsToSpacesFactory, + resolveCopySavedObjectsToSpacesConflictsFactory, +} from '../../../lib/copy_to_spaces'; +import { ExternalRouteDeps } from '.'; +import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from '../../../lib/copy_to_spaces/copy_to_spaces'; +import { SPACE_ID_REGEX } from '../../../lib/space_schema'; +import { createLicensedRouteHandler } from '../../lib'; + +type SavedObjectIdentifier = Pick; + +const areObjectsUnique = (objects: SavedObjectIdentifier[]) => + _.uniq(objects, (o: SavedObjectIdentifier) => `${o.type}:${o.id}`).length === objects.length; + +export function initCopyToSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, spacesService, getSavedObjects } = deps; + + externalRouter.post( + { + path: '/api/spaces/_copy_saved_objects', + options: { + tags: ['access:copySavedObjectsToSpaces'], + }, + validate: { + body: schema.object({ + spaces: schema.arrayOf( + schema.string({ + validate: value => { + if (!SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed`; + } + }, + }), + { + validate: spaceIds => { + if (_.uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ), + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }), + { + validate: objects => { + if (!areObjectsUnique(objects)) { + return 'duplicate objects are not allowed'; + } + }, + } + ), + includeReferences: schema.boolean({ defaultValue: false }), + overwrite: schema.boolean({ defaultValue: false }), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const savedObjectsClient = getSavedObjects().getScopedSavedObjectsClient( + request, + COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS + ); + const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( + savedObjectsClient, + getSavedObjects() + ); + const { spaces: destinationSpaceIds, objects, includeReferences, overwrite } = request.body; + const sourceSpaceId = spacesService.getSpaceId(request); + const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { + objects, + includeReferences, + overwrite, + }); + return response.ok({ body: copyResponse }); + }) + ); + + externalRouter.post( + { + path: '/api/spaces/_resolve_copy_saved_objects_errors', + options: { + tags: ['access:copySavedObjectsToSpaces'], + }, + validate: { + body: schema.object({ + retries: schema.recordOf( + schema.string({ + validate: spaceId => { + if (!SPACE_ID_REGEX.test(spaceId)) { + return `Invalid space id: ${spaceId}`; + } + }, + }), + schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + overwrite: schema.boolean({ defaultValue: false }), + }) + ) + ), + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }), + { + validate: objects => { + if (!areObjectsUnique(objects)) { + return 'duplicate objects are not allowed'; + } + }, + } + ), + includeReferences: schema.boolean({ defaultValue: false }), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const savedObjectsClient = getSavedObjects().getScopedSavedObjectsClient( + request, + COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS + ); + const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( + savedObjectsClient, + getSavedObjects() + ); + const { objects, includeReferences, retries } = request.body; + const sourceSpaceId = spacesService.getSpaceId(request); + const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts( + sourceSpaceId, + { + objects, + includeReferences, + retries, + } + ); + return response.ok({ body: resolveConflictsResponse }); + }) + ); +} 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 new file mode 100644 index 0000000000000..e341bd3e4bcbb --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -0,0 +1,164 @@ +/* + * 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 Rx from 'rxjs'; +import { + createSpaces, + createLegacyAPI, + createMockSavedObjectsRepository, + mockRouteContext, + mockRouteContextWithInvalidLicense, +} from '../__fixtures__'; +import { CoreSetup, IRouter, kibanaResponseFactory } from 'src/core/server'; +import { + loggingServiceMock, + elasticsearchServiceMock, + httpServiceMock, + httpServerMock, +} from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initDeleteSpacesApi } from './delete'; +import { RouteSchemas } from 'src/core/server/http/router/route'; +import { ObjectType } from '@kbn/config-schema'; +import { spacesConfig } from '../../../lib/__fixtures__'; + +describe('Spaces Public API', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + + const legacyAPI = createLegacyAPI({ spaces }); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const log = loggingServiceMock.create().get('spaces'); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (httpService as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of(spacesConfig), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + spacesConfig, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initDeleteSpacesApi({ + externalRouter: router, + getSavedObjects: () => legacyAPI.savedObjects, + log, + spacesService, + }); + + const [routeDefinition, routeHandler] = router.delete.mock.calls[0]; + + return { + routeValidation: routeDefinition.validate as RouteSchemas, + routeHandler, + }; + }; + + it('requires a space id as part of the path', async () => { + const { routeValidation } = await setup(); + expect(() => routeValidation.params!.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[id]: expected value of type [string] but got [undefined]"` + ); + }); + + it(`deletes the space`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: 'a-space', + }, + method: 'delete', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(204); + }); + + it(`returns http/403 when the license is invalid`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: 'a-space', + }, + method: 'delete', + }); + + const response = await routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it('throws when deleting a non-existent space', async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: 'not-a-space', + }, + method: 'delete', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(404); + }); + + it(`DELETE spaces/{id}' cannot delete reserved spaces`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: 'default', + }, + method: 'delete', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status, payload } = response; + + expect(status).toEqual(400); + expect(payload.message).toEqual('This Space cannot be deleted because it is reserved.'); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.ts new file mode 100644 index 0000000000000..536efdc1de649 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { wrapError } from '../../../lib/errors'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { ExternalRouteDeps } from '.'; +import { createLicensedRouteHandler } from '../../lib'; + +export function initDeleteSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, getSavedObjects, spacesService } = deps; + + externalRouter.delete( + { + path: '/api/spaces/space/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const { SavedObjectsClient } = getSavedObjects(); + const spacesClient: SpacesClient = await spacesService.scopedClient(request); + + const id = request.params.id; + + try { + await spacesClient.delete(id); + } catch (error) { + if (SavedObjectsClient.errors.isNotFoundError(error)) { + return response.notFound(); + } + return response.customError(wrapError(error)); + } + + return response.noContent(); + }) + ); +} 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 new file mode 100644 index 0000000000000..69c4f16d4ca80 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts @@ -0,0 +1,127 @@ +/* + * 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 Rx from 'rxjs'; +import { + createSpaces, + createLegacyAPI, + createMockSavedObjectsRepository, + mockRouteContextWithInvalidLicense, + mockRouteContext, +} from '../__fixtures__'; +import { initGetSpaceApi } from './get'; +import { CoreSetup, IRouter, kibanaResponseFactory } from 'src/core/server'; +import { + loggingServiceMock, + elasticsearchServiceMock, + httpServiceMock, + httpServerMock, +} from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { spacesConfig } from '../../../lib/__fixtures__'; + +describe('GET space', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + + const legacyAPI = createLegacyAPI({ spaces }); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const log = loggingServiceMock.create().get('spaces'); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (httpService as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of(spacesConfig), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + spacesConfig, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initGetSpaceApi({ + externalRouter: router, + getSavedObjects: () => legacyAPI.savedObjects, + log, + spacesService, + }); + + return { + routeHandler: router.get.mock.calls[0][1], + }; + }; + + it(`returns http/403 when the license is invalid`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'get', + }); + + const response = await routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it(`returns the space with that id`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: 'default', + }, + method: 'get', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual(spaces.find(s => s.id === 'default')); + }); + + it(`'GET spaces/{id}' returns 404 when retrieving a non-existent space`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: 'not-a-space', + }, + method: 'get', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(404); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.ts b/x-pack/plugins/spaces/server/routes/api/external/get.ts new file mode 100644 index 0000000000000..7643ec811db71 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/get.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { wrapError } from '../../../lib/errors'; +import { ExternalRouteDeps } from '.'; +import { createLicensedRouteHandler } from '../../lib'; + +export function initGetSpaceApi(deps: ExternalRouteDeps) { + const { externalRouter, spacesService, getSavedObjects } = deps; + + externalRouter.get( + { + path: '/api/spaces/space/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const spaceId = request.params.id; + + const { SavedObjectsClient } = getSavedObjects(); + const spacesClient = await spacesService.scopedClient(request); + + try { + const space = await spacesClient.get(spaceId); + return response.ok({ body: space }); + } catch (error) { + if (SavedObjectsClient.errors.isNotFoundError(error)) { + return response.notFound(); + } + return response.customError(wrapError(error)); + } + }) + ); +} 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 new file mode 100644 index 0000000000000..fd31b7d084c0e --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -0,0 +1,141 @@ +/* + * 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 Rx from 'rxjs'; +import { + createSpaces, + createLegacyAPI, + createMockSavedObjectsRepository, + mockRouteContext, + mockRouteContextWithInvalidLicense, +} from '../__fixtures__'; +import { CoreSetup, kibanaResponseFactory, IRouter } from 'src/core/server'; +import { + loggingServiceMock, + elasticsearchServiceMock, + httpServiceMock, + httpServerMock, +} from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initGetAllSpacesApi } from './get_all'; +import { spacesConfig } from '../../../lib/__fixtures__'; + +describe('GET /spaces/space', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + + const legacyAPI = createLegacyAPI({ spaces }); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const log = loggingServiceMock.create().get('spaces'); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (httpService as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of(spacesConfig), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + spacesConfig, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initGetAllSpacesApi({ + externalRouter: router, + getSavedObjects: () => legacyAPI.savedObjects, + log, + spacesService, + }); + + return { + routeHandler: router.get.mock.calls[0][1], + }; + }; + + it(`returns all available spaces`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'get', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual(spaces); + }); + + it(`returns all available spaces with the 'any' purpose`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + query: { + purpose: 'any', + }, + method: 'get', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual(spaces); + }); + + it(`returns all available spaces with the 'copySavedObjectsIntoSpace' purpose`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + query: { + purpose: 'copySavedObjectsIntoSpace', + }, + method: 'get', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual(spaces); + }); + + it(`returns http/403 when the license is invalid`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'get', + }); + + const response = await routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.ts new file mode 100644 index 0000000000000..cd1e03eb10b0a --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.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 { schema } from '@kbn/config-schema'; +import { Space } from '../../../../common/model/space'; +import { wrapError } from '../../../lib/errors'; +import { ExternalRouteDeps } from '.'; +import { createLicensedRouteHandler } from '../../lib'; + +export function initGetAllSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, log, spacesService } = deps; + + externalRouter.get( + { + path: '/api/spaces/space', + validate: { + query: schema.object({ + purpose: schema.oneOf( + [schema.literal('any'), schema.literal('copySavedObjectsIntoSpace')], + { + defaultValue: 'any', + } + ), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + log.debug(`Inside GET /api/spaces/space`); + + const purpose = request.query.purpose; + + const spacesClient = await spacesService.scopedClient(request); + + let spaces: Space[]; + + try { + log.debug(`Attempting to retrieve all spaces for ${purpose} purpose`); + spaces = await spacesClient.getAll(purpose); + log.debug(`Retrieved ${spaces.length} spaces for ${purpose} purpose`); + } catch (error) { + log.debug(`Error retrieving spaces for ${purpose} purpose: ${error}`); + return response.customError(wrapError(error)); + } + + return response.ok({ body: spaces }); + }) + ); +} diff --git a/x-pack/plugins/spaces/server/routes/api/external/index.ts b/x-pack/plugins/spaces/server/routes/api/external/index.ts new file mode 100644 index 0000000000000..60b0170ee04a7 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/index.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 { Logger, SavedObjectsLegacyService, IRouter } from 'src/core/server'; +import { initDeleteSpacesApi } from './delete'; +import { initGetSpaceApi } from './get'; +import { initGetAllSpacesApi } from './get_all'; +import { initPostSpacesApi } from './post'; +import { initPutSpacesApi } from './put'; +import { SpacesServiceSetup } from '../../../spaces_service/spaces_service'; +import { initCopyToSpacesApi } from './copy_to_space'; + +export interface ExternalRouteDeps { + externalRouter: IRouter; + getSavedObjects: () => SavedObjectsLegacyService; + spacesService: SpacesServiceSetup; + log: Logger; +} + +export function initExternalSpacesApi(deps: ExternalRouteDeps) { + initDeleteSpacesApi(deps); + initGetSpaceApi(deps); + initGetAllSpacesApi(deps); + initPostSpacesApi(deps); + initPutSpacesApi(deps); + initCopyToSpacesApi(deps); +} 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 new file mode 100644 index 0000000000000..f874f96833350 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts @@ -0,0 +1,179 @@ +/* + * 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 Rx from 'rxjs'; +import { + createSpaces, + createLegacyAPI, + createMockSavedObjectsRepository, + mockRouteContext, + mockRouteContextWithInvalidLicense, +} from '../__fixtures__'; +import { CoreSetup, kibanaResponseFactory, IRouter } from 'src/core/server'; +import { + loggingServiceMock, + elasticsearchServiceMock, + httpServerMock, + httpServiceMock, +} from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initPostSpacesApi } from './post'; +import { RouteSchemas } from 'src/core/server/http/router/route'; +import { ObjectType } from '@kbn/config-schema'; +import { spacesConfig } from '../../../lib/__fixtures__'; + +describe('Spaces Public API', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + + const legacyAPI = createLegacyAPI({ spaces }); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const log = loggingServiceMock.create().get('spaces'); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (httpService as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of(spacesConfig), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + spacesConfig, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initPostSpacesApi({ + externalRouter: router, + getSavedObjects: () => legacyAPI.savedObjects, + log, + spacesService, + }); + + const [routeDefinition, routeHandler] = router.post.mock.calls[0]; + + return { + routeValidation: routeDefinition.validate as RouteSchemas, + routeHandler, + savedObjectsRepositoryMock, + }; + }; + + it('should create a new space with the provided ID', async () => { + const payload = { + id: 'my-space-id', + name: 'my new space', + description: 'with a description', + disabledFeatures: ['foo'], + }; + + const { routeHandler, savedObjectsRepositoryMock } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.create).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.create).toHaveBeenCalledWith( + 'space', + { name: 'my new space', description: 'with a description', disabledFeatures: ['foo'] }, + { id: 'my-space-id' } + ); + }); + + it(`returns http/403 when the license is invalid`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'post', + }); + + const response = await routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it('should not allow a space to be updated', async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: 'with a description', + }; + + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status, payload: responsePayload } = response; + + expect(status).toEqual(409); + expect(responsePayload.message).toEqual('space conflict'); + }); + + it('should not require disabledFeatures to be specified', async () => { + const payload = { + id: 'my-space-id', + name: 'my new space', + description: 'with a description', + }; + + const { routeValidation, routeHandler, savedObjectsRepositoryMock } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: routeValidation.body!.validate(payload), + method: 'post', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.create).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.create).toHaveBeenCalledWith( + 'space', + { name: 'my new space', description: 'with a description', disabledFeatures: [] }, + { id: 'my-space-id' } + ); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.ts b/x-pack/plugins/spaces/server/routes/api/external/post.ts new file mode 100644 index 0000000000000..3a24df8b7270e --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/post.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Boom from 'boom'; +import { wrapError } from '../../../lib/errors'; +import { spaceSchema } from '../../../lib/space_schema'; +import { ExternalRouteDeps } from '.'; +import { createLicensedRouteHandler } from '../../lib'; + +export function initPostSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, log, spacesService, getSavedObjects } = deps; + + externalRouter.post( + { + path: '/api/spaces/space', + validate: { + body: spaceSchema, + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + log.debug(`Inside POST /api/spaces/space`); + const { SavedObjectsClient } = getSavedObjects(); + const spacesClient = await spacesService.scopedClient(request); + + const space = request.body; + + try { + log.debug(`Attempting to create space`); + const createdSpace = await spacesClient.create(space); + return response.ok({ body: createdSpace }); + } catch (error) { + if (SavedObjectsClient.errors.isConflictError(error)) { + const { body } = wrapError( + Boom.conflict(`A space with the identifier ${space.id} already exists.`) + ); + return response.conflict({ body }); + } + log.debug(`Error creating space: ${error}`); + return response.customError(wrapError(error)); + } + }) + ); +} 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 new file mode 100644 index 0000000000000..b06bb41fe8b6b --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts @@ -0,0 +1,219 @@ +/* + * 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 Rx from 'rxjs'; +import { + createSpaces, + createLegacyAPI, + createMockSavedObjectsRepository, + mockRouteContext, + mockRouteContextWithInvalidLicense, +} from '../__fixtures__'; +import { CoreSetup, IRouter, kibanaResponseFactory } from 'src/core/server'; +import { + loggingServiceMock, + elasticsearchServiceMock, + httpServiceMock, + httpServerMock, +} from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initPutSpacesApi } from './put'; +import { RouteSchemas } from 'src/core/server/http/router/route'; +import { ObjectType } from '@kbn/config-schema'; +import { spacesConfig } from '../../../lib/__fixtures__'; + +describe('PUT /api/spaces/space', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + + const legacyAPI = createLegacyAPI({ spaces }); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const log = loggingServiceMock.create().get('spaces'); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (httpService as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of(spacesConfig), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + spacesConfig, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initPutSpacesApi({ + externalRouter: router, + getSavedObjects: () => legacyAPI.savedObjects, + log, + spacesService, + }); + + const [routeDefinition, routeHandler] = router.put.mock.calls[0]; + + return { + routeValidation: routeDefinition.validate as RouteSchemas, + routeHandler, + savedObjectsRepositoryMock, + }; + }; + + it('should update an existing space with the provided ID', async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: 'with a description', + disabledFeatures: [], + }; + + const { routeHandler, savedObjectsRepositoryMock } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: payload.id, + }, + body: payload, + method: 'post', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledWith('space', 'a-space', { + name: 'my updated space', + description: 'with a description', + disabledFeatures: [], + }); + }); + + it('should allow an empty description', async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: '', + disabledFeatures: ['foo'], + }; + + const { routeHandler, savedObjectsRepositoryMock } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: payload.id, + }, + body: payload, + method: 'post', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledWith('space', 'a-space', { + name: 'my updated space', + description: '', + disabledFeatures: ['foo'], + }); + }); + + it('should not require disabledFeatures', async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: '', + }; + + const { routeHandler, routeValidation, savedObjectsRepositoryMock } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: payload.id, + }, + body: routeValidation.body!.validate(payload), + method: 'post', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledWith('space', 'a-space', { + name: 'my updated space', + description: '', + disabledFeatures: [], + }); + }); + + it('should not allow a new space to be created', async () => { + const payload = { + id: 'a-new-space', + name: 'my new space', + description: 'with a description', + }; + + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: payload.id, + }, + body: payload, + method: 'post', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(404); + }); + + it(`returns http/403 when the license is invalid`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'post', + }); + + const response = await routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.ts b/x-pack/plugins/spaces/server/routes/api/external/put.ts new file mode 100644 index 0000000000000..4c19b0bd2edda --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/put.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { Space } from '../../../../common/model/space'; +import { wrapError } from '../../../lib/errors'; +import { spaceSchema } from '../../../lib/space_schema'; +import { ExternalRouteDeps } from '.'; +import { createLicensedRouteHandler } from '../../lib'; + +export function initPutSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, spacesService, getSavedObjects } = deps; + + externalRouter.put( + { + path: '/api/spaces/space/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: spaceSchema, + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const { SavedObjectsClient } = getSavedObjects(); + const spacesClient = await spacesService.scopedClient(request); + + const space = request.body; + const id = request.params.id; + + let result: Space; + try { + result = await spacesClient.update(id, { ...space }); + } catch (error) { + if (SavedObjectsClient.errors.isNotFoundError(error)) { + return response.notFound(); + } + return response.customError(wrapError(error)); + } + + return response.ok({ body: result }); + }) + ); +} diff --git a/x-pack/legacy/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts b/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts rename to x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts diff --git a/x-pack/legacy/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts b/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts rename to x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts diff --git a/x-pack/legacy/plugins/spaces/server/routes/lib/index.ts b/x-pack/plugins/spaces/server/routes/lib/index.ts similarity index 81% rename from x-pack/legacy/plugins/spaces/server/routes/lib/index.ts rename to x-pack/plugins/spaces/server/routes/lib/index.ts index af67388792565..068ff67d3d136 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/lib/index.ts +++ b/x-pack/plugins/spaces/server/routes/lib/index.ts @@ -5,4 +5,4 @@ */ export { convertSavedObjectToSpace } from './convert_saved_object_to_space'; -export { getSpaceById } from './get_space_by_id'; +export { createLicensedRouteHandler } from './licensed_route_handler'; diff --git a/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts b/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts new file mode 100644 index 0000000000000..a3bc2fa71fefe --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts @@ -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 { RequestHandler } from 'src/core/server'; +import { ObjectType } from '@kbn/config-schema'; +import { LICENSE_STATUS } from '../../../../licensing/server/constants'; + +export const createLicensedRouteHandler = < + P extends ObjectType, + Q extends ObjectType, + B extends ObjectType +>( + handler: RequestHandler +) => { + const licensedRouteHandler: RequestHandler = (context, request, responseToolkit) => { + const { license } = context.licensing; + const licenseCheck = license.check('spaces', 'basic'); + if ( + licenseCheck.check === LICENSE_STATUS.Unavailable || + licenseCheck.check === LICENSE_STATUS.Invalid + ) { + return responseToolkit.forbidden({ body: { message: licenseCheck.message! } }); + } + + return handler(context, request, responseToolkit); + }; + + return licensedRouteHandler; +}; diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/index.ts b/x-pack/plugins/spaces/server/spaces_service/index.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/index.ts rename to x-pack/plugins/spaces/server/spaces_service/index.ts diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.mock.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.mock.ts similarity index 76% rename from x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.mock.ts rename to x-pack/plugins/spaces/server/spaces_service/spaces_service.mock.ts index bcbd377d60ee1..6f21330368f8d 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.mock.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.mock.ts @@ -5,12 +5,12 @@ */ import { SpacesServiceSetup } from './spaces_service'; -import { spacesClientMock } from '../../lib/spaces_client/spaces_client.mock'; -import { DEFAULT_SPACE_ID } from '../../../common/constants'; -import { namespaceToSpaceId, spaceIdToNamespace } from '../../lib/utils/namespace'; +import { spacesClientMock } from '../lib/spaces_client/spaces_client.mock'; +import { DEFAULT_SPACE_ID } from '../../common/constants'; +import { namespaceToSpaceId, spaceIdToNamespace } from '../lib/utils/namespace'; const createSetupContractMock = (spaceId = DEFAULT_SPACE_ID) => { - const setupContract: SpacesServiceSetup = { + const setupContract: jest.Mocked = { getSpaceId: jest.fn().mockReturnValue(spaceId), isInDefaultSpace: jest.fn().mockReturnValue(spaceId === DEFAULT_SPACE_ID), getBasePath: jest.fn().mockReturnValue(''), diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.test.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts similarity index 92% rename from x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.test.ts rename to x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts index 817474dc0fb3a..d0910e00586ed 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.test.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts @@ -6,16 +6,18 @@ import * as Rx from 'rxjs'; import { SpacesService } from './spaces_service'; import { coreMock, elasticsearchServiceMock } from 'src/core/server/mocks'; -import { SpacesAuditLogger } from '../../lib/audit_logger'; +import { SpacesAuditLogger } from '../lib/audit_logger'; import { KibanaRequest, SavedObjectsLegacyService, SavedObjectsErrorHelpers, + HttpServiceSetup, } from 'src/core/server'; -import { DEFAULT_SPACE_ID } from '../../../common/constants'; -import { createOptionalPlugin } from '../../../../../server/lib/optional_plugin'; +import { DEFAULT_SPACE_ID } from '../../common/constants'; +import { getSpaceIdFromPath } from '../../common/lib/spaces_url_parser'; import { LegacyAPI } from '../plugin'; -import { getSpaceIdFromPath } from '../../../common'; +import { createOptionalPlugin } from '../../../../legacy/server/lib/optional_plugin'; +import { spacesConfig } from '../lib/__fixtures__'; const mockLogger = { trace: jest.fn(), @@ -29,9 +31,7 @@ const mockLogger = { const createService = async (serverBasePath: string = '') => { const legacyAPI = { - legacyConfig: { - serverBasePath, - }, + legacyConfig: {}, savedObjects: ({ getSavedObjectsRepository: jest.fn().mockReturnValue({ get: jest.fn().mockImplementation((type, id) => { @@ -63,6 +63,9 @@ const createService = async (serverBasePath: string = '') => { const spacesService = new SpacesService(mockLogger, () => legacyAPI); const httpSetup = coreMock.createSetup().http; + httpSetup.basePath = { + serverBasePath, + } as HttpServiceSetup['basePath']; httpSetup.basePath.get = jest.fn().mockImplementation((request: KibanaRequest) => { const spaceId = getSpaceIdFromPath(request.url.path); @@ -75,8 +78,8 @@ const createService = async (serverBasePath: string = '') => { const spacesServiceSetup = await spacesService.setup({ http: httpSetup, elasticsearch: elasticsearchServiceMock.createSetupContract(), - config$: Rx.of({ maxSpaces: 10 }), - security: createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + config$: Rx.of(spacesConfig), + getSecurity: () => createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), getSpacesAuditLogger: () => new SpacesAuditLogger({}), }); diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts similarity index 81% rename from x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.ts rename to x-pack/plugins/spaces/server/spaces_service/spaces_service.ts index 08ebc2cb31748..83a62f91ade01 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts @@ -8,15 +8,15 @@ import { map, take } from 'rxjs/operators'; import { Observable, Subscription, combineLatest } from 'rxjs'; import { Legacy } from 'kibana'; import { Logger, KibanaRequest, CoreSetup } from 'src/core/server'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; -import { DEFAULT_SPACE_ID } from '../../../common/constants'; -import { SecurityPlugin } from '../../../../security'; -import { SpacesClient } from '../../lib/spaces_client'; -import { SpacesConfigType } from '../config'; -import { namespaceToSpaceId, spaceIdToNamespace } from '../../lib/utils/namespace'; +import { SecurityPlugin } from '../../../../legacy/plugins/security'; +import { OptionalPlugin } from '../../../../legacy/server/lib/optional_plugin'; import { LegacyAPI } from '../plugin'; -import { Space } from '../../../common/model/space'; -import { getSpaceIdFromPath, addSpaceIdToPath } from '../../../common'; +import { SpacesClient } from '../lib/spaces_client'; +import { ConfigType } from '../config'; +import { getSpaceIdFromPath, addSpaceIdToPath } from '../../common/lib/spaces_url_parser'; +import { DEFAULT_SPACE_ID } from '../../common/constants'; +import { spaceIdToNamespace, namespaceToSpaceId } from '../lib/utils/namespace'; +import { Space } from '../../common/model/space'; type RequestFacade = KibanaRequest | Legacy.Request; @@ -39,8 +39,8 @@ export interface SpacesServiceSetup { interface SpacesServiceDeps { http: CoreSetup['http']; elasticsearch: CoreSetup['elasticsearch']; - security: OptionalPlugin; - config$: Observable; + getSecurity: () => OptionalPlugin; + config$: Observable; getSpacesAuditLogger(): any; } @@ -52,7 +52,7 @@ export class SpacesService { public async setup({ http, elasticsearch, - security, + getSecurity, config$, getSpacesAuditLogger, }: SpacesServiceDeps): Promise { @@ -64,7 +64,7 @@ export class SpacesService { ? (request as Record).getBasePath() : http.basePath.get(request); - const spaceId = getSpaceIdFromPath(basePath, this.getServerBasePath()); + const spaceId = getSpaceIdFromPath(basePath, http.basePath.serverBasePath); return spaceId; }; @@ -85,6 +85,8 @@ export class SpacesService { ['space'] ); + const security = getSecurity(); + const authorization = security.isEnabled ? security.authorization : null; return new SpacesClient( @@ -110,7 +112,7 @@ export class SpacesService { if (!spaceId) { throw new TypeError(`spaceId is required to retrieve base path`); } - return addSpaceIdToPath(this.getServerBasePath(), spaceId); + return addSpaceIdToPath(http.basePath.serverBasePath, spaceId); }, isInDefaultSpace: (request: RequestFacade) => { const spaceId = getSpaceId(request); @@ -134,8 +136,4 @@ export class SpacesService { this.configSubscription$ = undefined; } } - - private getServerBasePath() { - return this.getLegacyAPI().legacyConfig.serverBasePath; - } } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0f7fe7c0eefc0..d8b8aa2bd5d4c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -781,7 +781,6 @@ "data.filter.filterBar.filterItemBadgeIconAriaLabel": "削除", "data.filter.filterBar.includeFilterButtonLabel": "結果を含める", "data.filter.filterBar.indexPatternSelectPlaceholder": "インデックスパターンの選択", - "data.filter.filterBar.moreFilterActionsMessage": "他のフィルターアクションを使用するには選択してください。", "data.filter.filterBar.negatedFilterPrefix": "NOT ", "data.filter.filterBar.pinFilterButtonLabel": "すべてのアプリにピン付け", "data.filter.filterBar.pinnedFilterPrefix": "ピン付け済み", @@ -7380,7 +7379,6 @@ "xpack.ml.newJob.wizard.summaryStep.viewResultsButton": "結果を表示", "xpack.ml.newJob.wizard.timeRangeStep.timeRangePicker.endDateLabel": "終了日", "xpack.ml.newJob.wizard.timeRangeStep.timeRangePicker.startDateLabel": "開始日", - "xpack.ml.newJob.wizard.validateJob.bucketSpanInvalidTimeIntervalFormatErrorMessage": "{bucketSpan} は有効な時間間隔のフォーマット (例: {tenMinutes}、{oneHour}) ではありません。また、0 よりも大きい数字である必要があります。", "xpack.ml.newJob.wizard.validateJob.bucketSpanMustBeSetErrorMessage": "バケットスパンを設定する必要があります", "xpack.ml.newJob.wizard.validateJob.duplicatedDetectorsErrorMessage": "重複する検知器が検出されました。", "xpack.ml.newJob.wizard.validateJob.groupNameAlreadyExists": "グループ ID が既に存在します。グループ ID は既存のジョブやグループと同じにできません。", @@ -9581,19 +9579,6 @@ "xpack.siem.network.emptyActionPrimary": "セットアップの手順を表示", "xpack.siem.network.emptyActionSecondary": "ドキュメントに移動", "xpack.siem.network.emptyTitle": "SIEM アプリケーションのネットワークに関連したインデックスがないようです", - "xpack.siem.network.ipDetails.domainsTable.columns.bytesTitle": "バイト", - "xpack.siem.network.ipDetails.domainsTable.columns.directionTitle": "方向", - "xpack.siem.network.ipDetails.domainsTable.columns.domainNameTitle": "ドメイン名", - "xpack.siem.network.ipDetails.domainsTable.columns.firstLastSeenToolTip": "選択された日付範囲との相関付けです", - "xpack.siem.network.ipDetails.domainsTable.columns.lastSeenTitle": "前回の認識", - "xpack.siem.network.ipDetails.domainsTable.columns.packetsTitle": "パケット", - "xpack.siem.network.ipDetails.domainsTable.columns.uniqueClientsTitle": "固有のサーバー", - "xpack.siem.network.ipDetails.domainsTable.columns.uniqueDestinationsTitle": "固有のデスティネーション", - "xpack.siem.network.ipDetails.domainsTable.columns.uniqueServersTitle": "固有のクライアント", - "xpack.siem.network.ipDetails.domainsTable.columns.uniqueSourcesTitle": "固有のソース", - "xpack.siem.network.ipDetails.domainsTable.domainsTitle": "ドメイン", - "xpack.siem.network.ipDetails.domainsTable.rows": "{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}", - "xpack.siem.network.ipDetails.domainsTable.unit": "{totalCount, plural, =1 {domain} other {domains}}", "xpack.siem.network.ipDetails.ipOverview.autonomousSystemTitle": "自動システム", "xpack.siem.network.ipDetails.ipOverview.firstSeenTitle": "初回の認識", "xpack.siem.network.ipDetails.ipOverview.hostIdTitle": "ホスト ID", @@ -9841,16 +9826,13 @@ "xpack.siem.components.mlPopup.anomalyDetectionTitle": "異常検知設定", "xpack.siem.components.mlPopup.errors.createJobFailureTitle": "ジョブ作成エラー", "xpack.siem.components.mlPopup.errors.startJobFailureTitle": "ジョブ開始エラー", - "xpack.siem.components.mlPopup.filterPlaceholder": "例: rare_process_linux", "xpack.siem.components.mlPopup.hooks.errors.indexPatternFetchFailureTitle": "インデックスパターン取得エラー", - "xpack.siem.components.mlPopup.hooks.errors.jobSummaryFetchFailureTitle": "ジョブサマリー取得エラー", "xpack.siem.components.mlPopup.hooks.errors.siemJobFetchFailureTitle": "SIEM ジョブ取得エラー", "xpack.siem.components.mlPopup.jobsTable.createCustomJobButtonLabel": "カスタムジョブを作成", "xpack.siem.components.mlPopup.jobsTable.jobNameColumn": "ジョブ名", "xpack.siem.components.mlPopup.jobsTable.noItemsDescription": "SIEM 機械学習ジョブが見つかりませんでした", "xpack.siem.components.mlPopup.jobsTable.runJobColumn": "ジョブを実行", "xpack.siem.components.mlPopup.machineLearningLink": "Machine Learning", - "xpack.siem.components.mlPopup.showAllJobsLabel": "Elastic ジョブ", "xpack.siem.components.mlPopup.showingLabel": "{filterResultsLength} 件の{filterResultsLength, plural, one {ジョブ} other {ジョブ}}を表示中", "xpack.siem.components.mlPopup.upgradeButtonLabel": "サブスクリプションオプション", "xpack.siem.components.mlPopup.upgradeTitle": "E lastic Platinum へのアップグレード", @@ -9917,7 +9899,6 @@ "xpack.siem.uiSettings.defaultRefreshIntervalLabel": "タイムピッカーの更新間隔", "xpack.siem.uiSettings.defaultTimeRangeDescription": "SIEM 時間フィルダーが選択されずに Kibana が起動した際に使用される SIEM 時間フィルターです", "xpack.siem.uiSettings.defaultTimeRangeLabel": "デフォルトのタイムピッカー", - "xpack.siem.components.mlPopup.showSiemJobsLabel": "カスタムジョブ", "xpack.siem.host.details.overview.maxAnomalyScoreByJobTitle": "ジョブ別の最高異常スコア", "xpack.siem.ml.score.anomalyJobTitle": "ジョブ", "xpack.siem.ml.table.detectorTitle": "ジョブ名", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b485eef915d57..5670102deacf8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -782,7 +782,6 @@ "data.filter.filterBar.filterItemBadgeIconAriaLabel": "删除", "data.filter.filterBar.includeFilterButtonLabel": "包括结果", "data.filter.filterBar.indexPatternSelectPlaceholder": "选择索引模式", - "data.filter.filterBar.moreFilterActionsMessage": "选择更多筛选操作。", "data.filter.filterBar.negatedFilterPrefix": "非 ", "data.filter.filterBar.pinFilterButtonLabel": "在所有应用上固定", "data.filter.filterBar.pinnedFilterPrefix": "已固定", @@ -7483,7 +7482,6 @@ "xpack.ml.newJob.wizard.summaryStep.viewResultsButton": "查看结果", "xpack.ml.newJob.wizard.timeRangeStep.timeRangePicker.endDateLabel": "结束日期", "xpack.ml.newJob.wizard.timeRangeStep.timeRangePicker.startDateLabel": "开始日期", - "xpack.ml.newJob.wizard.validateJob.bucketSpanInvalidTimeIntervalFormatErrorMessage": "{bucketSpan} 不是有效的时间间隔格式,例如,{tenMinutes}、{oneHour}。它还需要大于零。", "xpack.ml.newJob.wizard.validateJob.bucketSpanMustBeSetErrorMessage": "必须设置存储桶跨度", "xpack.ml.newJob.wizard.validateJob.duplicatedDetectorsErrorMessage": "找到重复的检测工具。", "xpack.ml.newJob.wizard.validateJob.groupNameAlreadyExists": "组 ID 已存在。组 ID 不能与现有作业或组相同。", @@ -9746,19 +9744,6 @@ "xpack.siem.network.emptyActionPrimary": "查看设置说明", "xpack.siem.network.emptyActionSecondary": "前往文档", "xpack.siem.network.emptyTitle": "似乎您在 SIEM 应用程序中没有与网络相关的索引", - "xpack.siem.network.ipDetails.domainsTable.columns.bytesTitle": "字节", - "xpack.siem.network.ipDetails.domainsTable.columns.directionTitle": "方向", - "xpack.siem.network.ipDetails.domainsTable.columns.domainNameTitle": "域名", - "xpack.siem.network.ipDetails.domainsTable.columns.firstLastSeenToolTip": "相对于选定日期范围", - "xpack.siem.network.ipDetails.domainsTable.columns.lastSeenTitle": "最后看到时间", - "xpack.siem.network.ipDetails.domainsTable.columns.packetsTitle": "数据包", - "xpack.siem.network.ipDetails.domainsTable.columns.uniqueClientsTitle": "唯一服务器", - "xpack.siem.network.ipDetails.domainsTable.columns.uniqueDestinationsTitle": "唯一目标", - "xpack.siem.network.ipDetails.domainsTable.columns.uniqueServersTitle": "唯一客户端", - "xpack.siem.network.ipDetails.domainsTable.columns.uniqueSourcesTitle": "唯一源", - "xpack.siem.network.ipDetails.domainsTable.domainsTitle": "域", - "xpack.siem.network.ipDetails.domainsTable.rows": "{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}", - "xpack.siem.network.ipDetails.domainsTable.unit": "{totalCount, plural, =1 {domain} other {domains}}", "xpack.siem.network.ipDetails.ipOverview.autonomousSystemTitle": "自治系统", "xpack.siem.network.ipDetails.ipOverview.firstSeenTitle": "首次看到时间", "xpack.siem.network.ipDetails.ipOverview.hostIdTitle": "主机 ID", @@ -10006,16 +9991,13 @@ "xpack.siem.components.mlPopup.anomalyDetectionTitle": "异常检测设置", "xpack.siem.components.mlPopup.errors.createJobFailureTitle": "创建作业失败", "xpack.siem.components.mlPopup.errors.startJobFailureTitle": "启动作业失败", - "xpack.siem.components.mlPopup.filterPlaceholder": "例如 rare_process_linux", "xpack.siem.components.mlPopup.hooks.errors.indexPatternFetchFailureTitle": "索引模式提取失败", - "xpack.siem.components.mlPopup.hooks.errors.jobSummaryFetchFailureTitle": "作业摘要提取失败", "xpack.siem.components.mlPopup.hooks.errors.siemJobFetchFailureTitle": "SIEM 作业提取失败", "xpack.siem.components.mlPopup.jobsTable.createCustomJobButtonLabel": "创建定制作业", "xpack.siem.components.mlPopup.jobsTable.jobNameColumn": "作业名称", "xpack.siem.components.mlPopup.jobsTable.noItemsDescription": "未找到任何 SIEM Machine Learning 作业", "xpack.siem.components.mlPopup.jobsTable.runJobColumn": "运行作业", "xpack.siem.components.mlPopup.machineLearningLink": "Machine Learning", - "xpack.siem.components.mlPopup.showAllJobsLabel": "Elastic 作业", "xpack.siem.components.mlPopup.showingLabel": "显示:{filterResultsLength} 个 {filterResultsLength, plural, one {作业} other {作业}}", "xpack.siem.components.mlPopup.upgradeButtonLabel": "订阅选项", "xpack.siem.components.mlPopup.upgradeTitle": "升级 Elastic 白金级", @@ -10082,7 +10064,6 @@ "xpack.siem.uiSettings.defaultRefreshIntervalLabel": "时间选取器刷新时间间隔", "xpack.siem.uiSettings.defaultTimeRangeDescription": "未使用时间筛选启动 Kibana 时要使用的 SIEM 时间筛选选择", "xpack.siem.uiSettings.defaultTimeRangeLabel": "时间选取器默认值", - "xpack.siem.components.mlPopup.showSiemJobsLabel": "定制作业", "xpack.siem.host.details.overview.maxAnomalyScoreByJobTitle": "最大异常分数(按作业)", "xpack.siem.ml.score.anomalyJobTitle": "作业", "xpack.siem.ml.table.detectorTitle": "作业名称", diff --git a/x-pack/test/api_integration/apis/siem/domains.ts b/x-pack/test/api_integration/apis/siem/domains.ts deleted file mode 100644 index 7965feb0e30f4..0000000000000 --- a/x-pack/test/api_integration/apis/siem/domains.ts +++ /dev/null @@ -1,141 +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 expect from '@kbn/expect'; -import { domainsQuery } from '../../../../legacy/plugins/siem/public/containers/domains/index.gql_query'; -import { - Direction, - DomainsFields, - FlowDirection, - FlowTarget, - GetDomainsQuery, -} from '../../../../legacy/plugins/siem/public/graphql/types'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -const FROM = new Date('2000-01-01T00:00:00.000Z').valueOf(); -const TO = new Date('3000-01-01T00:00:00.000Z').valueOf(); -const IP = '10.100.7.196'; - -export default function({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const client = getService('siemGraphQLClient'); - describe('Domains', () => { - describe('With filebeat', () => { - before(() => esArchiver.load('filebeat/default')); - after(() => esArchiver.unload('filebeat/default')); - - it('Ensure data is returned for FlowTarget.Source and Direction.Unidirectional', () => { - return client - .query({ - query: domainsQuery, - variables: { - sourceId: 'default', - timerange: { - interval: '12h', - to: TO, - from: FROM, - }, - ip: IP, - flowDirection: FlowDirection.uniDirectional, - flowTarget: FlowTarget.source, - sort: { field: DomainsFields.bytes, direction: Direction.desc }, - pagination: { - activePage: 0, - cursorStart: 0, - fakePossibleCount: 30, - querySize: 10, - }, - defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - inspect: false, - }, - }) - .then(resp => { - const domains = resp.data.source.Domains; - expect(domains.edges.length).to.be(1); - expect(domains.totalCount).to.be(1); - expect(domains.edges[0].node.source!.uniqueIpCount).to.be(122); - expect(domains.edges[0].node.source!.domainName).to.be( - 'samsungtv-kitchen.iot.sr.local.crowbird.com' - ); - expect(domains.edges[0].node.network!.bytes).to.be(25209932); - }); - }); - - it('Ensure data is returned for FlowTarget.Source and Direction.Bidirectional', () => { - return client - .query({ - query: domainsQuery, - variables: { - sourceId: 'default', - timerange: { - interval: '12h', - to: TO, - from: FROM, - }, - ip: IP, - flowDirection: FlowDirection.biDirectional, - flowTarget: FlowTarget.source, - sort: { field: DomainsFields.bytes, direction: Direction.desc }, - pagination: { - activePage: 0, - cursorStart: 0, - fakePossibleCount: 30, - querySize: 10, - }, - defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - inspect: false, - }, - }) - .then(resp => { - const domains = resp.data.source.Domains; - expect(domains.edges.length).to.be(1); - expect(domains.totalCount).to.be(1); - expect(domains.edges[0].node.source!.domainName).to.be( - 'samsungtv-kitchen.iot.sr.local.crowbird.com' - ); - expect(domains.edges[0].node.source!.uniqueIpCount).to.be(81); - expect(domains.edges[0].node.network!.bytes).to.be(27033419); - }); - }); - - it('Ensure data is returned for FlowTarget.Destination and Direction.Unidirectional and Pagination works', () => { - return client - .query({ - query: domainsQuery, - variables: { - sourceId: 'default', - timerange: { - interval: '12h', - to: TO, - from: FROM, - }, - ip: IP, - flowDirection: FlowDirection.uniDirectional, - flowTarget: FlowTarget.destination, - sort: { field: DomainsFields.bytes, direction: Direction.desc }, - pagination: { - activePage: 0, - cursorStart: 0, - fakePossibleCount: 30, - querySize: 10, - }, - defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - inspect: false, - }, - }) - .then(resp => { - const domains = resp.data.source.Domains; - expect(domains.edges.length).to.be(10); - expect(domains.totalCount).to.be(12); - expect(domains.edges.map(i => i.node.destination!.domainName).join(',')).to.be( - 'samsungtv-kitchen.iot.sr.local.crowbird.com,12s3.lvlt.dash.row.aiv-cdn.net,151.205.0.17,151.205.0.19,151.205.0.21,151.205.0.23,15s3.lvlt.dash.row.aiv-cdn.net,api-global.netflix.com,d25xi40x97liuc.cloudfront.net,d2lkq7nlcrdi7q.cloudfront.net' - ); - expect(domains.pageInfo.fakeTotalCount).to.equal(12); - }); - }); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/siem/index.js b/x-pack/test/api_integration/apis/siem/index.js index a28c2f42a52df..77edf1e55c63c 100644 --- a/x-pack/test/api_integration/apis/siem/index.js +++ b/x-pack/test/api_integration/apis/siem/index.js @@ -7,7 +7,6 @@ export default function ({ loadTestFile }) { describe('Siem GraphQL Endpoints', () => { loadTestFile(require.resolve('./authentications')); - loadTestFile(require.resolve('./domains')); loadTestFile(require.resolve('./events_over_time')); loadTestFile(require.resolve('./hosts')); loadTestFile(require.resolve('./kpi_network')); diff --git a/x-pack/test/api_integration/apis/siem/network_top_n_flow.ts b/x-pack/test/api_integration/apis/siem/network_top_n_flow.ts index 9b5861dfedb4c..efa0dc9c72d9c 100644 --- a/x-pack/test/api_integration/apis/siem/network_top_n_flow.ts +++ b/x-pack/test/api_integration/apis/siem/network_top_n_flow.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { networkTopNFlowQuery } from '../../../../legacy/plugins/siem/public/containers/network_top_n_flow/index.gql_query'; import { Direction, - FlowTargetNew, + FlowTargetSourceDest, GetNetworkTopNFlowQuery, NetworkTopNFlowFields, } from '../../../../legacy/plugins/siem/public/graphql/types'; @@ -38,7 +38,7 @@ export default function({ getService }: FtrProviderContext) { to: TO, from: FROM, }, - flowTarget: FlowTargetNew.source, + flowTarget: FlowTargetSourceDest.source, sort: { field: NetworkTopNFlowFields.bytes_in, direction: Direction.desc }, pagination: { activePage: 0, @@ -75,7 +75,7 @@ export default function({ getService }: FtrProviderContext) { to: TO, from: FROM, }, - flowTarget: FlowTargetNew.source, + flowTarget: FlowTargetSourceDest.source, sort: { field: NetworkTopNFlowFields.bytes_in, direction: Direction.asc }, pagination: { activePage: 0, @@ -113,7 +113,7 @@ export default function({ getService }: FtrProviderContext) { from: FROM, }, sort: { field: NetworkTopNFlowFields.bytes_in, direction: Direction.desc }, - flowTarget: FlowTargetNew.destination, + flowTarget: FlowTargetSourceDest.destination, pagination: { activePage: 0, cursorStart: 0, @@ -147,7 +147,7 @@ export default function({ getService }: FtrProviderContext) { from: FROM, }, sort: { field: NetworkTopNFlowFields.bytes_in, direction: Direction.desc }, - flowTarget: FlowTargetNew.source, + flowTarget: FlowTargetSourceDest.source, pagination: { activePage: 1, cursorStart: 10, diff --git a/x-pack/test/api_integration/apis/spaces/space_attributes.ts b/x-pack/test/api_integration/apis/spaces/space_attributes.ts index b3863cd82835c..8b68ad8c9ccca 100644 --- a/x-pack/test/api_integration/apis/spaces/space_attributes.ts +++ b/x-pack/test/api_integration/apis/spaces/space_attributes.ts @@ -63,13 +63,8 @@ export default function({ getService }: FtrProviderContext) { }) .expect(400, { error: 'Bad Request', - message: - 'child "imageUrl" fails because ["imageUrl" with value "invalidImage" fails to match the Image URL should start with \'data:image\' pattern]', + message: "[request body.imageUrl]: must start with 'data:image'", statusCode: 400, - validation: { - keys: ['imageUrl'], - source: 'payload', - }, }); }); }); 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 3779065962c68..9dca3f40c6303 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 @@ -84,6 +84,8 @@ export default function({ getService }: FtrProviderContext) { }); describe('monitor state scoping', async () => { + const numIps = 4; // Must be > 2 for IP uniqueness checks + before('load heartbeat data', () => getService('esArchiver').load('uptime/blank')); after('unload heartbeat index', () => getService('esArchiver').unload('uptime/blank')); @@ -106,7 +108,7 @@ export default function({ getService }: FtrProviderContext) { const es = getService('es'); dateRangeStart = new Date().toISOString(); - checks = await makeChecks(es, index, testMonitorId, 1, 2, {}, d => { + checks = await makeChecks(es, index, testMonitorId, 1, numIps, {}, d => { if (d.summary) { d.monitor.status = 'down'; d.summary.up--; @@ -118,6 +120,17 @@ export default function({ getService }: FtrProviderContext) { nonSummaryIp = checks[0][0].monitor.ip; }); + it('should return all IPs', async () => { + const res = await getMonitorStates(makeApiParams(testMonitorId)); + + const uniqueIps = new Set(); + res.monitorStates.summaries[0].state.checks.forEach((c: any) => + uniqueIps.add(c.monitor.ip) + ); + + expect(uniqueIps.size).to.eql(4); + }); + it('should match non summary documents without a status filter', async () => { const params = makeApiParams(testMonitorId, [{ match: { 'monitor.ip': nonSummaryIp } }]); diff --git a/x-pack/test/functional/apps/maps/saved_object_management.js b/x-pack/test/functional/apps/maps/saved_object_management.js index 1468546108317..fdb7df29eaf0c 100644 --- a/x-pack/test/functional/apps/maps/saved_object_management.js +++ b/x-pack/test/functional/apps/maps/saved_object_management.js @@ -109,7 +109,7 @@ export default function ({ getPageObjects, getService }) { it('should update app state with query stored with map', async () => { const currentUrl = await browser.getCurrentUrl(); const appState = currentUrl.substring(currentUrl.indexOf('_a=')); - expect(appState).to.equal('_a=(filters:!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,index:c698b940-e149-11e8-a35a-370a8516603a,key:machine.os.raw,negate:!f,params:(query:ios),type:phrase,value:ios),query:(match:(machine.os.raw:(query:ios,type:phrase))))),query:(language:kuery,query:%27%27))'); // eslint-disable-line max-len + expect(appState).to.equal('_a=(filters:!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,index:c698b940-e149-11e8-a35a-370a8516603a,key:machine.os.raw,negate:!f,params:(query:ios),type:phrase),query:(match:(machine.os.raw:(query:ios,type:phrase))))),query:(language:kuery,query:%27%27))'); // eslint-disable-line max-len }); it('should apply query stored with map', async () => { diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index 61f1ab2190a09..85e877912ab6c 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -7,8 +7,8 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { EsArchiver } from 'src/es_archiver'; -import { DEFAULT_SPACE_ID } from '../../../../legacy/plugins/spaces/common/constants'; -import { CopyResponse } from '../../../../legacy/plugins/spaces/server/lib/copy_to_spaces'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { CopyResponse } from '../../../../plugins/spaces/server/lib/copy_to_spaces'; import { getUrlPrefix } from '../lib/space_test_utils'; import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; diff --git a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts index 5d32d9f8adcf2..20b4d024803d7 100644 --- a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -9,7 +9,7 @@ import { SuperTest } from 'supertest'; import { EsArchiver } from 'src/es_archiver'; import { SavedObject } from 'src/core/server'; import { DEFAULT_SPACE_ID } from '../../../../legacy/plugins/spaces/common/constants'; -import { CopyResponse } from '../../../../legacy/plugins/spaces/server/lib/copy_to_spaces'; +import { CopyResponse } from '../../../../plugins/spaces/server/lib/copy_to_spaces'; import { getUrlPrefix } from '../lib/space_test_utils'; import { DescribeFn, TestDefinitionAuthentication } from '../lib/types';