diff --git a/.ci/build_docker.sh b/.ci/build_docker.sh index 1f45182aad8408..07013f13cdae5e 100755 --- a/.ci/build_docker.sh +++ b/.ci/build_docker.sh @@ -7,4 +7,8 @@ cd "$(dirname "${0}")" cp /usr/local/bin/runbld ./ cp /usr/local/bin/bash_standard_lib.sh ./ -docker build -t kibana-ci -f ./Dockerfile . +if which docker >/dev/null; then + docker build -t kibana-ci -f ./Dockerfile . +else + echo "Docker binary is not available. Skipping the docker build this time." +fi diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index be2b4533e22db6..834662044988de 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -69,7 +69,8 @@ /x-pack/plugins/apm/ @elastic/apm-ui /x-pack/test/functional/apps/apm/ @elastic/apm-ui /src/plugins/apm_oss/ @elastic/apm-ui -/src/apm.js @watson @vigneshshanmugam +/src/apm.js @elastic/kibana-core @vigneshshanmugam +/packages/kbn-apm-config-loader/ @elastic/kibana-core @vigneshshanmugam #CC# /src/plugins/apm_oss/ @elastic/apm-ui #CC# /x-pack/plugins/observability/ @elastic/apm-ui diff --git a/README.md b/README.md index b786d95ce29942..6977c198e904d7 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Kibana is your window into the [Elastic Stack](https://www.elastic.co/products). If you just want to try Kibana out, check out the [Elastic Stack Getting Started Page](https://www.elastic.co/start) to give it a whirl. -If you're interested in diving a bit deeper and getting a taste of Kibana's capabilities, head over to the [Kibana Getting Started Page](https://www.elastic.co/guide/en/kibana/current/getting-started.html). +If you're interested in diving a bit deeper and getting a taste of Kibana's capabilities, head over to the [Kibana Getting Started Page](https://www.elastic.co/guide/en/kibana/current/get-started.html). ### Using a Kibana Release diff --git a/docs/developer/architecture/add-data-tutorials.asciidoc b/docs/developer/architecture/add-data-tutorials.asciidoc index 3891b87a00e64b..8b6f7d54483644 100644 --- a/docs/developer/architecture/add-data-tutorials.asciidoc +++ b/docs/developer/architecture/add-data-tutorials.asciidoc @@ -28,11 +28,11 @@ Then register the tutorial object by calling `home.tutorials.registerTutorial(tu String values can contain variables that are substituted when rendered. Variables are specified by `{}`. For example: `{config.docs.version}` is rendered as `6.2` when running the tutorial in {kib} 6.2. -link:{kib-repo}tree/{branch}/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/replace_template_strings.js#L23[Provided variables] +link:{kib-repo}tree/{branch}/src/plugins/home/public/application/components/tutorial/replace_template_strings.js[Provided variables] [discrete] ==== Markdown String values can contain limited Markdown syntax. -link:{kib-repo}tree/{branch}/src/legacy/core_plugins/kibana/public/home/components/tutorial/content.js#L8[Enabled Markdown grammars] +link:{kib-repo}tree/{branch}/src/legacy/core_plugins/kibana/public/home/components/tutorial/content.js[Enabled Markdown grammars] diff --git a/docs/developer/architecture/core/index.asciidoc b/docs/developer/architecture/core/index.asciidoc new file mode 100644 index 00000000000000..48595690f9784e --- /dev/null +++ b/docs/developer/architecture/core/index.asciidoc @@ -0,0 +1,451 @@ +[[kibana-platform-api]] +== {kib} Core API + +experimental[] + +{kib} Core provides a set of low-level API's required to run all {kib} plugins. +These API's are injected into your plugin's lifecycle methods and may be invoked during that lifecycle only: + +[source,typescript] +---- +import type { PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; + +export class MyPlugin { + constructor(initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup) { + // called when plugin is setting up during Kibana's startup sequence + } + + public start(core: CoreStart) { + // called after all plugins are set up + } + + public stop() { + // called when plugin is torn down during Kibana's shutdown sequence + } +} +---- + +=== Server-side +[[configuration-service]] +==== Configuration service +{kib} provides `ConfigService` if a plugin developer may want to support +adjustable runtime behavior for their plugins. +Plugins can only read their own configuration values, it is not possible to access the configuration values from {kib} Core or other plugins directly. + +[source,js] +---- +// in Legacy platform +const basePath = config.get('server.basePath'); +// in Kibana Platform 'basePath' belongs to the http service +const basePath = core.http.basePath.get(request); +---- + +To have access to your plugin config, you _should_: + +* Declare plugin-specific `configPath` (will fallback to plugin `id` +if not specified) in {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`kibana.json`] manifest file. +* Export schema validation for the config from plugin's main file. Schema is +mandatory. If a plugin reads from the config without schema declaration, +`ConfigService` will throw an error. + +*my_plugin/server/index.ts* +[source,typescript] +---- +import { schema, TypeOf } from '@kbn/config-schema'; +export const plugin = … +export const config = { + schema: schema.object(…), +}; +export type MyPluginConfigType = TypeOf; +---- + +* Read config value exposed via `PluginInitializerContext`. +*my_plugin/server/index.ts* +[source,typescript] +---- +import type { PluginInitializerContext } from 'kibana/server'; +export class MyPlugin { + constructor(initializerContext: PluginInitializerContext) { + this.config$ = initializerContext.config.create(); + // or if config is optional: + this.config$ = initializerContext.config.createIfExists(); + } +---- + +If your plugin also has a client-side part, you can also expose +configuration properties to it using the configuration `exposeToBrowser` +allow-list property. + +*my_plugin/server/index.ts* +[source,typescript] +---- +import { schema, TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor } from 'kibana/server'; + +const configSchema = schema.object({ + secret: schema.string({ defaultValue: 'Only on server' }), + uiProp: schema.string({ defaultValue: 'Accessible from client' }), +}); + +type ConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + uiProp: true, + }, + schema: configSchema, +}; +---- + +Configuration containing only the exposed properties will be then +available on the client-side using the plugin's `initializerContext`: + +*my_plugin/public/index.ts* +[source,typescript] +---- +interface ClientConfigType { + uiProp: string; +} + +export class MyPlugin implements Plugin { + constructor(private readonly initializerContext: PluginInitializerContext) {} + + public async setup(core: CoreSetup, deps: {}) { + const config = this.initializerContext.config.get(); + } +---- + +All plugins are considered enabled by default. If you want to disable +your plugin, you could declare the `enabled` flag in the plugin +config. This is a special {kib} Platform key. {kib} reads its +value and won’t create a plugin instance if `enabled: false`. + +[source,js] +---- +export const config = { + schema: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), +}; +---- +[[handle-plugin-configuration-deprecations]] +===== Handle plugin configuration deprecations +If your plugin has deprecated configuration keys, you can describe them using +the `deprecations` config descriptor field. +Deprecations are managed on a per-plugin basis, meaning you don’t need to specify +the whole property path, but use the relative path from your plugin’s +configuration root. + +*my_plugin/server/index.ts* +[source,typescript] +---- +import { schema, TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor } from 'kibana/server'; + +const configSchema = schema.object({ + newProperty: schema.string({ defaultValue: 'Some string' }), +}); + +type ConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + deprecations: ({ rename, unused }) => [ + rename('oldProperty', 'newProperty'), + unused('someUnusedProperty'), + ], +}; +---- + +In some cases, accessing the whole configuration for deprecations is +necessary. For these edge cases, `renameFromRoot` and `unusedFromRoot` +are also accessible when declaring deprecations. + +*my_plugin/server/index.ts* +[source,typescript] +---- +export const config: PluginConfigDescriptor = { + schema: configSchema, + deprecations: ({ renameFromRoot, unusedFromRoot }) => [ + renameFromRoot('oldplugin.property', 'myplugin.property'), + unusedFromRoot('oldplugin.deprecated'), + ], +}; +---- +==== Logging service +Allows a plugin to provide status and diagnostic information. +For detailed instructions see the {kib-repo}blob/{branch}/src/core/server/logging/README.md[logging service documentation]. + +[source,typescript] +---- +import type { PluginInitializerContext, CoreSetup, Plugin, Logger } from 'kibana/server'; + +export class MyPlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + try { + this.logger.debug('doing something...'); + // … + } catch (e) { + this.logger.error('failed doing something...'); + } + } +} +---- + +==== Elasticsearch service +`Elasticsearch service` provides `elasticsearch.client` program API to communicate with Elasticsearch server REST API. +`elasticsearch.client` interacts with Elasticsearch service on behalf of: + +- `kibana_system` user via `elasticsearch.client.asInternalUser.*` methods. +- a current end-user via `elasticsearch.client.asCurrentUser.*` methods. In this case Elasticsearch client should be given the current user credentials. +See <> and <>. + +{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md[Elasticsearch service API docs] + +[source,typescript] +---- +import { CoreStart, Plugin } from 'kibana/public'; + +export class MyPlugin implements Plugin { + public start(core: CoreStart) { + async function asyncTask() { + const result = await core.elasticsearch.client.asInternalUser.ping(…); + } + asyncTask(); + } +} +---- + +For advanced use-cases, such as a search, use {kib-repo}blob/{branch}/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md[Data plugin] + +include::saved-objects-service.asciidoc[leveloffset=+1] + +==== HTTP service +Allows plugins: + +* to extend the {kib} server with custom REST API. +* to execute custom logic on an incoming request or server response. +* implement custom authentication and authorization strategy. + +See {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md[HTTP service API docs] + +[source,typescript] +---- +import { schema } from '@kbn/config-schema'; +import type { CoreSetup, Plugin } from 'kibana/server'; + +export class MyPlugin implements Plugin { + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + + const validate = { + params: schema.object({ + id: schema.string(), + }), + }; + + router.get({ + path: 'my_plugin/{id}', + validate + }, + async (context, request, response) => { + const data = await findObject(request.params.id); + if (!data) return response.notFound(); + return response.ok({ + body: data, + headers: { + 'content-type': 'application/json' + } + }); + }); + } +} +---- + +==== UI settings service +The program interface to <>. +It makes it possible for Kibana plugins to extend Kibana UI Settings Management with custom settings. + +See: + +- {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.uisettingsservicesetup.register.md[UI settings service Setup API docs] +- {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.uisettingsservicestart.register.md[UI settings service Start API docs] + +[source,typescript] +---- +import { schema } from '@kbn/config-schema'; +import type { CoreSetup,Plugin } from 'kibana/server'; + +export class MyPlugin implements Plugin { + public setup(core: CoreSetup) { + core.uiSettings.register({ + custom: { + value: '42', + schema: schema.string(), + }, + }); + const router = core.http.createRouter(); + router.get({ + path: 'my_plugin/{id}', + validate: …, + }, + async (context, request, response) => { + const customSetting = await context.uiSettings.client.get('custom'); + … + }); + } +} + +---- + +=== Client-side +==== Application service +Kibana has migrated to be a Single Page Application. Plugins should use `Application service` API to instruct Kibana what an application should be loaded & rendered in the UI in response to user interactions. +[source,typescript] +---- +import { AppMountParameters, CoreSetup, Plugin, DEFAULT_APP_CATEGORIES } from 'kibana/public'; + +export class MyPlugin implements Plugin { + public setup(core: CoreSetup) { + core.application.register({ // <1> + category: DEFAULT_APP_CATEGORIES.kibana, + id: 'my-plugin', + title: 'my plugin title', + euiIconType: '/path/to/some.svg', + order: 100, + appRoute: '/app/my_plugin', // <2> + async mount(params: AppMountParameters) { // <3> + // Load application bundle + const { renderApp } = await import('./application'); + // Get start services + const [coreStart, depsStart] = await core.getStartServices(); // <4> + // Render the application + return renderApp(coreStart, depsStart, params); // <5> + }, + }); + } +} +---- +<1> See {kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md[application.register interface] +<2> Application specific URL. +<3> `mount` callback is invoked when a user navigates to the application-specific URL. +<4> `core.getStartServices` method provides API available during `start` lifecycle. +<5> `mount` method must return a function that will be called to unmount the application. + +NOTE:: you are free to use any UI library to render a plugin application in DOM. +However, we recommend using React and https://elastic.github.io/eui[EUI] for all your basic UI +components to create a consistent UI experience. + +==== HTTP service +Provides API to communicate with the {kib} server. Feel free to use another HTTP client library to request 3rd party services. + +[source,typescript] +---- +import { CoreStart } from 'kibana/public'; +interface ResponseType {…}; +async function fetchData(core: CoreStart) { + return await core.http.get<>( + '/api/my_plugin/', + { query: … }, + ); +} +---- +See {kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.httpsetup.md[for all available API]. + +==== Elasticsearch service +Not available in the browser. Use {kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md[Data plugin] instead. + +== Patterns +[[scoped-services]] +=== Scoped services +Whenever Kibana needs to get access to data saved in elasticsearch, it +should perform a check whether an end-user has access to the data. In +the legacy platform, Kibana requires binding elasticsearch related API +with an incoming request to access elasticsearch service on behalf of a +user. + +[source,js] +---- +async function handler(req, res) { + const dataCluster = server.plugins.elasticsearch.getCluster('data'); + const data = await dataCluster.callWithRequest(req, 'ping'); +} +---- + +The Kibana Platform introduced a handler interface on the server-side to perform that association +internally. Core services, that require impersonation with an incoming +request, are exposed via `context` argument of +{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.requesthandler.md[the +request handler interface.] The above example looks in the Kibana Platform +as + +[source,js] +---- +async function handler(context, req, res) { + const data = await context.core.elasticsearch.client.asCurrentUser('ping'); +} +---- + +The +{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md[request +handler context] exposed the next scoped *core* services: + +[width="100%",cols="30%,70%",options="header",] +|=== +|Legacy Platform |Kibana Platform +|`request.getSavedObjectsClient` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md[`context.savedObjects.client`] + +|`server.plugins.elasticsearch.getCluster('admin')` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md[`context.elasticsearch.client`] + +|`server.plugins.elasticsearch.getCluster('data')` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md[`context.elasticsearch.client`] + +|`request.getUiSettingsService` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iuisettingsclient.md[`context.uiSettings.client`] +|=== + +==== Declare a custom scoped service + +Plugins can extend the handler context with a custom API that will be +available to the plugin itself and all dependent plugins. For example, +the plugin creates a custom elasticsearch client and wants to use it via +the request handler context: + +[source,typescript] +---- +import type { CoreSetup, IScopedClusterClient } from 'kibana/server'; + +export interface MyPluginContext { + client: IScopedClusterClient; +} + +// extend RequestHandlerContext when a dependent plugin imports MyPluginContext from the file +declare module 'kibana/server' { + interface RequestHandlerContext { + myPlugin?: MyPluginContext; + } +} + +class MyPlugin { + setup(core: CoreSetup) { + const client = core.elasticsearch.createClient('myClient'); + core.http.registerRouteHandlerContext('myPlugin', (context, req, res) => { + return { client: client.asScoped(req) }; + }); + const router = core.http.createRouter(); + router.get( + { path: '/api/my-plugin/', validate: … }, + async (context, req, res) => { + const data = await context.myPlugin.client.asCurrentUser('endpoint'); + } + ); + } +---- diff --git a/docs/developer/architecture/development-plugin-saved-objects.asciidoc b/docs/developer/architecture/core/saved-objects-service.asciidoc similarity index 98% rename from docs/developer/architecture/development-plugin-saved-objects.asciidoc rename to docs/developer/architecture/core/saved-objects-service.asciidoc index 0d31f5d90f6680..047c3dffa63583 100644 --- a/docs/developer/architecture/development-plugin-saved-objects.asciidoc +++ b/docs/developer/architecture/core/saved-objects-service.asciidoc @@ -1,7 +1,7 @@ -[[development-plugin-saved-objects]] -== Using Saved Objects +[[saved-objects-service]] +== Saved Objects service -Saved Objects allow {kib} plugins to use {es} like a primary +`Saved Objects service` allows {kib} plugins to use {es} like a primary database. Think of it as an Object Document Mapper for {es}. Once a plugin has registered one or more Saved Object types, the Saved Objects client can be used to query or perform create, read, update and delete operations on diff --git a/docs/developer/architecture/index.asciidoc b/docs/developer/architecture/index.asciidoc index dc15b90b69d1a6..7fa7d80ef97293 100644 --- a/docs/developer/architecture/index.asciidoc +++ b/docs/developer/architecture/index.asciidoc @@ -3,9 +3,15 @@ [IMPORTANT] ============================================== -{kib} developer services and apis are in a state of constant development. We cannot provide backwards compatibility at this time due to the high rate of change. +The {kib} Plugin APIs are in a state of +constant development. We cannot provide backwards compatibility at this time due +to the high rate of change. ============================================== +To begin plugin development, we recommend reading our overview of how plugins work: + +* <> + Our developer services are changing all the time. One of the best ways to discover and learn about them is to read the available READMEs from all the plugins inside our {kib-repo}tree/{branch}/src/plugins[open source plugins folder] and our {kib-repo}/tree/{branch}/x-pack/plugins[commercial plugins folder]. @@ -14,17 +20,17 @@ A few services also automatically generate api documentation which can be browse A few notable services are called out below. +* <> * <> -* <> * <> * <> -include::security/index.asciidoc[leveloffset=+1] +include::kibana-platform-plugin-api.asciidoc[leveloffset=+1] -include::development-plugin-saved-objects.asciidoc[leveloffset=+1] +include::core/index.asciidoc[leveloffset=+1] + +include::security/index.asciidoc[leveloffset=+1] include::add-data-tutorials.asciidoc[leveloffset=+1] include::development-visualize-index.asciidoc[leveloffset=+1] - - diff --git a/docs/developer/architecture/kibana-platform-plugin-api.asciidoc b/docs/developer/architecture/kibana-platform-plugin-api.asciidoc new file mode 100644 index 00000000000000..2005a90bb87bb8 --- /dev/null +++ b/docs/developer/architecture/kibana-platform-plugin-api.asciidoc @@ -0,0 +1,347 @@ +[[kibana-platform-plugin-api]] +== {kib} Plugin API + +experimental[] + +{kib} platform plugins are a significant step toward stabilizing {kib} architecture for all the developers. +We made sure plugins could continue to use most of the same technologies they use today, at least from a technical perspective. + +=== Anatomy of a plugin + +Plugins are defined as classes and present themselves to {kib} +through a simple wrapper function. A plugin can have browser-side code, +server-side code, or both. There is no architectural difference between +a plugin in the browser and a plugin on the server. +In both places, you describe your plugin similarly, and you interact with +Core and other plugins in the same way. + +The basic file structure of a {kib} plugin named `demo` that +has both client-side and server-side code would be: + +[source,tree] +---- +plugins/ + demo + kibana.json [1] + public + index.ts [2] + plugin.ts [3] + server + index.ts [4] + plugin.ts [5] +---- + +*[1] `kibana.json`* is a static manifest file that is used to identify the +plugin and to specify if this plugin has server-side code, browser-side code, or both: + +[source,json] +---- +{ + "id": "demo", + "version": "kibana", + "server": true, + "ui": true +} +---- + +Learn about the {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[manifest +file format]. + +NOTE: `package.json` files are irrelevant to and ignored by {kib} for discovering and loading plugins. + +*[2] `public/index.ts`* is the entry point into the client-side code of +this plugin. It must export a function named `plugin`, which will +receive {kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.plugininitializercontext.md[a standard set of core capabilities] as an argument. +It should return an instance of its plugin class for +{kib} to load. + +[source,typescript] +---- +import type { PluginInitializerContext } from 'kibana/server'; +import { MyPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new MyPlugin(initializerContext); +} +---- + +*[3] `public/plugin.ts`* is the client-side plugin definition itself. +Technically speaking, it does not need to be a class or even a separate +file from the entry point, but _all plugins at Elastic_ should be +consistent in this way. See all {kib-repo}blob/{branch}/src/core/CONVENTIONS.md[conventions +for first-party Elastic plugins]. + +[source,typescript] +---- +import type { Plugin, PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; + +export class MyPlugin implements Plugin { + constructor(initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup) { + // called when plugin is setting up during Kibana's startup sequence + } + + public start(core: CoreStart) { + // called after all plugins are set up + } + + public stop() { + // called when plugin is torn down during Kibana's shutdown sequence + } +} +---- + +*[4] `server/index.ts`* is the entry-point into the server-side code of +this plugin. {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md[It is identical] in almost every way to the client-side +entry-point: + + +[source,typescript] +---- +import type { PluginInitializerContext } from 'kibana/server'; +import { MyPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new MyPlugin(initializerContext); +} +---- + +*[5] `server/plugin.ts`* is the server-side plugin definition. The +shape of this plugin is the same as it’s client-side counter-part: + +[source,typescript] +---- +import type { Plugin, PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; + +export class MyPlugin implements Plugin { + constructor(initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup) { + // called when plugin is setting up during Kibana's startup sequence + } + + public start(core: CoreStart) { + // called after all plugins are set up + } + + public stop() { + // called when plugin is torn down during Kibana's shutdown sequence + } +} +---- + +{kib} does not impose any technical restrictions on how the +the internals of a plugin are architected, though there are certain +considerations related to how plugins integrate with core APIs +and APIs exposed by other plugins that may greatly impact how +they are built. +[[plugin-lifecycles]] +=== Lifecycles & Core Services + +The various independent domains that makeup `core` are represented by a +series of services and many of those services expose public interfaces +that are provided to all plugins. Services expose different features +at different parts of their lifecycle. We describe the lifecycle of +core services and plugins with specifically-named functions on the +service definition. + +{kib} has three lifecycles: `setup`, +`start`, and `stop`. Each plugin's `setup` functions is called sequentially +while Kibana is setting up on the server or when it is being loaded in +the browser. The `start` functions are called sequentially after `setup` +has been completed for all plugins. The `stop` functions are called +sequentially while Kibana is gracefully shutting down the server or +when the browser tab or window is being closed. + +The table below explains how each lifecycle relates to the state +of Kibana. + +[width="100%",cols="10%, 15%, 37%, 38%",options="header",] +|=== +|lifecycle | purpose| server |browser +|_setup_ +|perform "registration" work to setup environment for runtime +|configure REST API endpoint, register saved object types, etc. +|configure application routes in SPA, register custom UI elements in extension points, etc. + +|_start_ +|bootstrap runtime logic +|respond to an incoming request, request Elasticsearch server, etc. +|start polling Kibana server, update DOM tree in response to user interactions, etc. + +|_stop_ +|cleanup runtime +|dispose of active handles before the server shutdown. +|store session data in the LocalStorage when the user navigates away from {kib}, etc. +|=== + +There is no equivalent behavior to `start` or `stop` in legacy plugins. +Conversely, there is no equivalent to `uiExports` in Kibana Platform plugins. +As a general rule of thumb, features that were registered via `uiExports` are +now registered during the `setup` phase. Most of everything else should move +to the `start` phase. + +The lifecycle-specific contracts exposed by core services are always +passed as the first argument to the equivalent lifecycle function in a +plugin. For example, the core `http` service exposes a function +`createRouter` to all plugin `setup` functions. To use this function to register +an HTTP route handler, a plugin just accesses it off of the first argument: + +[source, typescript] +---- +import type { CoreSetup } from 'kibana/server'; + +export class MyPlugin { + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + // handler is called when '/path' resource is requested with `GET` method + router.get({ path: '/path', validate: false }, (context, req, res) => res.ok({ content: 'ok' })); + } +} +---- + +Different service interfaces can and will be passed to `setup`, `start`, and +`stop` because certain functionality makes sense in the context of a +running plugin while other types of functionality may have restrictions +or may only make sense in the context of a plugin that is stopping. + +For example, the `stop` function in the browser gets invoked as part of +the `window.onbeforeunload` event, which means you can’t necessarily +execute asynchronous code here reliably. For that reason, +`core` likely wouldn’t provide any asynchronous functions to plugin +`stop` functions in the browser. + +The current lifecycle function for all plugins will be executed before the next +lifecycle starts. That is to say that all `setup` functions are executed before +any `start` functions are executed. + +These are the contracts exposed by the core services for each lifecycle: + +[cols=",,",options="header",] +|=== +|lifecycle |server contract|browser contract +|_contructor_ +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md[PluginInitializerContext] +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.plugininitializercontext.md[PluginInitializerContext] + +|_setup_ +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.coresetup.md[CoreSetup] +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.coresetup.md[CoreSetup] + +|_start_ +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.corestart.md[CoreStart] +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.corestart.md[CoreStart] + +|_stop_ | +|=== + +=== Integrating with other plugins + +Plugins can expose public interfaces for other plugins to consume. Like +`core`, those interfaces are bound to the lifecycle functions `setup` +and/or `start`. + +Anything returned from `setup` or `start` will act as the interface, and +while not a technical requirement, all first-party Elastic plugins +will expose types for that interface as well. Third party plugins +wishing to allow other plugins to integrate with it are also highly +encouraged to expose types for their plugin interfaces. + +*foobar plugin.ts:* + +[source, typescript] +---- +import type { Plugin } from 'kibana/server'; +export interface FoobarPluginSetup { <1> + getFoo(): string; +} + +export interface FoobarPluginStart { <1> + getBar(): string; +} + +export class MyPlugin implements Plugin { + public setup(): FoobarPluginSetup { + return { + getFoo() { + return 'foo'; + }, + }; + } + + public start(): FoobarPluginStart { + return { + getBar() { + return 'bar'; + }, + }; + } +} +---- +<1> We highly encourage plugin authors to explicitly declare public interfaces for their plugins. + +Unlike core, capabilities exposed by plugins are _not_ automatically +injected into all plugins. Instead, if a plugin wishes to use the public +interface provided by another plugin, it must first declare that +plugin as a dependency in it's {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`kibana.json`] manifest file. + +*demo kibana.json:* + +[source,json] +---- +{ + "id": "demo", + "requiredPlugins": ["foobar"], + "server": true, + "ui": true +} +---- + +With that specified in the plugin manifest, the appropriate interfaces +are then available via the second argument of `setup` and/or `start`: + +*demo plugin.ts:* + +[source,typescript] +---- +import type { CoreSetup, CoreStart } from 'kibana/server'; +import type { FoobarPluginSetup, FoobarPluginStart } from '../../foobar/server'; + +interface DemoSetupPlugins { <1> + foobar: FoobarPluginSetup; +} + +interface DemoStartPlugins { + foobar: FoobarPluginStart; +} + +export class AnotherPlugin { + public setup(core: CoreSetup, plugins: DemoSetupPlugins) { <2> + const { foobar } = plugins; + foobar.getFoo(); // 'foo' + foobar.getBar(); // throws because getBar does not exist + } + + public start(core: CoreStart, plugins: DemoStartPlugins) { <3> + const { foobar } = plugins; + foobar.getFoo(); // throws because getFoo does not exist + foobar.getBar(); // 'bar' + } + + public stop() {} +} +---- +<1> The interface for plugin's dependencies must be manually composed. You can +do this by importing the appropriate type from the plugin and constructing an +interface where the property name is the plugin's ID. +<2> These manually constructed types should then be used to specify the type of +the second argument to the plugin. +<3> Notice that the type for the setup and start lifecycles are different. Plugin lifecycle +functions can only access the APIs that are exposed _during_ that lifecycle. + +=== Migrating legacy plugins + +In Kibana 7.10, support for legacy plugins was removed. See +<> for detailed information on how to convert existing +legacy plugins to this new API. diff --git a/docs/developer/best-practices/index.asciidoc b/docs/developer/best-practices/index.asciidoc index 42b379e606898e..b048e59e6c98cb 100644 --- a/docs/developer/best-practices/index.asciidoc +++ b/docs/developer/best-practices/index.asciidoc @@ -12,6 +12,8 @@ Are you planning with scalability in mind? * Consider data with many fields * Consider data with high cardinality fields * Consider large data sets, that span a long time range +* Are you loading a minimal amount of JS code in the browser? +** See <> for more guidance. * Do you make lots of requests to the server? ** If so, have you considered using the streaming {kib-repo}tree/{branch}/src/plugins/bfetch[bfetch service]? @@ -140,6 +142,8 @@ Review: * <> * <> +include::performance.asciidoc[leveloffset=+1] + include::navigation.asciidoc[leveloffset=+1] include::stability.asciidoc[leveloffset=+1] diff --git a/docs/developer/best-practices/performance.asciidoc b/docs/developer/best-practices/performance.asciidoc new file mode 100644 index 00000000000000..70f27005db372f --- /dev/null +++ b/docs/developer/best-practices/performance.asciidoc @@ -0,0 +1,101 @@ +[[plugin-performance]] +== Keep {kib} fast + +*tl;dr*: Load as much code lazily as possible. Everyone loves snappy +applications with a responsive UI and hates spinners. Users deserve the +best experience whether they run {kib} locally or +in the cloud, regardless of their hardware and environment. + +There are 2 main aspects of the perceived speed of an application: loading time +and responsiveness to user actions. {kib} loads and bootstraps *all* +the plugins whenever a user lands on any page. It means that +every new application affects the overall _loading performance_, as plugin code is +loaded _eagerly_ to initialize the plugin and provide plugin API to dependent +plugins. + +However, it’s usually not necessary that the whole plugin code should be loaded +and initialized at once. The plugin could keep on loading code covering API functionality +on {kib} bootstrap, but load UI related code lazily on-demand, when an +application page or management section is mounted. +Always prefer to import UI root components lazily when possible (such as in `mount` +handlers). Even if their size may seem negligible, they are likely using +some heavy-weight libraries that will also be removed from the initial +plugin bundle, therefore, reducing its size by a significant amount. + +[source,typescript] +---- +import type { Plugin, CoreSetup, AppMountParameters } from 'kibana/public'; +export class MyPlugin implements Plugin { + setup(core: CoreSetup, plugins: SetupDeps) { + core.application.register({ + id: 'app', + title: 'My app', + async mount(params: AppMountParameters) { + const { mountApp } = await import('./app/mount_app'); + return mountApp(await core.getStartServices(), params); + }, + }); + plugins.management.sections.section.kibana.registerApp({ + id: 'app', + title: 'My app', + order: 1, + async mount(params) { + const { mountManagementSection } = await import('./app/mount_management_section'); + return mountManagementSection(coreSetup, params); + }, + }); + return { + doSomething() {}, + }; + } +} +---- + +=== Understanding plugin bundle size + +{kib} Platform plugins are pre-built with `@kbn/optimizer` +and distributed as package artifacts. This means that it is no +longer necessary for us to include the `optimizer` in the +distributable version of {kib}. Every plugin artifact contains all +plugin dependencies required to run the plugin, except some +stateful dependencies shared across plugin bundles via +`@kbn/ui-shared-deps`. This means that plugin artifacts _tend to +be larger_ than they were in the legacy platform. To understand the +current size of your plugin artifact, run `@kbn/optimizer` with: + +[source,bash] +---- +node scripts/build_kibana_platform_plugins.js --dist --profile --focus=my_plugin +---- + +and check the output in the `target` sub-folder of your plugin folder: + +[source,bash] +---- +ls -lh plugins/my_plugin/target/public/ +# output +# an async chunk loaded on demand +... 262K 0.plugin.js +# eagerly loaded chunk +... 50K my_plugin.plugin.js +---- + +You might see at least one js bundle - `my_plugin.plugin.js`. This is +the _only_ artifact loaded by {kib} during bootstrap in the +browser. The rule of thumb is to keep its size as small as possible. +Other lazily loaded parts of your plugin will be present in the same folder as +separate chunks under `{number}.myplugin.js` names. If you want to +investigate what your plugin bundle consists of, you need to run +`@kbn/optimizer` with `--profile` flag to generate a +https://webpack.js.org/api/stats/[webpack stats file]. + +[source,bash] +---- +node scripts/build_kibana_platform_plugins.js --dist --no-examples --profile +---- + +Many OSS tools allow you to analyze the generated stats file: + +* http://webpack.github.io/analyse/#modules[An official tool] from +Webpack authors +* https://chrisbateman.github.io/webpack-visualizer/[webpack-visualizer] diff --git a/docs/developer/contributing/development-ci-metrics.asciidoc b/docs/developer/contributing/development-ci-metrics.asciidoc index 485b7af6a62211..9c54ef9c8a916c 100644 --- a/docs/developer/contributing/development-ci-metrics.asciidoc +++ b/docs/developer/contributing/development-ci-metrics.asciidoc @@ -75,7 +75,7 @@ In order to prevent the page load bundles from growing unexpectedly large we lim In most cases the limit should be high enough that PRs shouldn't trigger overages, but when they do make sure it's clear what is cuasing the overage by trying the following: -1. Run the optimizer locally with the `--profile` flag to produce webpack `stats.json` files for bundles which can be inspected using a number of different online tools. Focus on the chunk named `{pluginId}.plugin.js`; the `*.chunk.js` chunks make up the `async chunks size` metric which is currently unlimited and is the main way that we {kib-repo}blob/{branch}/src/core/MIGRATION.md#keep-kibana-fast[reduce the size of page load chunks]. +1. Run the optimizer locally with the `--profile` flag to produce webpack `stats.json` files for bundles which can be inspected using a number of different online tools. Focus on the chunk named `{pluginId}.plugin.js`; the `*.chunk.js` chunks make up the `async chunks size` metric which is currently unlimited and is the main way that we <>. + [source,shell] ----------- @@ -111,7 +111,7 @@ prettier -w {pluginDir}/target/public/{pluginId}.plugin.js 6. If all else fails reach out to Operations for help. -Once you've identified the files which were added to the build you likely just need to stick them behind an async import as described in {kib-repo}blob/{branch}/src/core/MIGRATION.md#keep-kibana-fast[the MIGRATION.md docs]. +Once you've identified the files which were added to the build you likely just need to stick them behind an async import as described in <>. In the case that the bundle size is not being bloated by anything obvious, but it's still larger than the limit, you can raise the limit in your PR. Do this either by editting the {kib-repo}blob/{branch}/packages/kbn-optimizer/limits.yml[`limits.yml` file] manually or by running the following to have the limit updated to the current size + 15kb diff --git a/docs/developer/getting-started/development-plugin-resources.asciidoc b/docs/developer/getting-started/development-plugin-resources.asciidoc index 1fe211c87c6605..863a67f3c42f04 100644 --- a/docs/developer/getting-started/development-plugin-resources.asciidoc +++ b/docs/developer/getting-started/development-plugin-resources.asciidoc @@ -51,8 +51,9 @@ but not in the distributable version of {kib}. If you use the [discrete] === {kib} platform migration guide -{kib-repo}blob/{branch}/src/core/MIGRATION.md#migrating-legacy-plugins-to-the-new-platform[This guide] -provides an action plan for moving a legacy plugin to the new platform. +<> +provides an action plan for moving a legacy plugin to the new platform. +<> migration examples for the legacy core services. [discrete] === Externally developed plugins diff --git a/docs/developer/plugin/index.asciidoc b/docs/developer/plugin/index.asciidoc index dd83cf234dea45..c74e4c91ef2781 100644 --- a/docs/developer/plugin/index.asciidoc +++ b/docs/developer/plugin/index.asciidoc @@ -9,34 +9,16 @@ The {kib} plugin interfaces are in a state of constant development. We cannot p Most developers who contribute code directly to the {kib} repo are writing code inside plugins, so our <> docs are the best place to start. However, there are a few differences when developing plugins outside the {kib} repo. These differences are covered here. -[discrete] -[[automatic-plugin-generator]] -=== Automatic plugin generator - -We recommend that you kick-start your plugin by generating it with the {kib-repo}tree/{branch}/packages/kbn-plugin-generator[Kibana Plugin Generator]. Run the following in the {kib} repo, and you will be asked a couple questions, see some progress bars, and have a freshly generated plugin ready for you to play with in {kib}'s `plugins` folder. - -["source","shell"] ------------ -node scripts/generate_plugin my_plugin_name # replace "my_plugin_name" with your desired plugin name ------------ - -[discrete] -=== Plugin location - -The {kib} directory must be named `kibana`, and your plugin directory should be located in the root of `kibana` in a `plugins` directory, for example: - -["source","shell"] ----- -. -└── kibana - └── plugins - ├── foo-plugin - └── bar-plugin ----- - +* <> +* <> +* <> * <> * <> +* <> +include::plugin-tooling.asciidoc[leveloffset=+1] +include::migrating-legacy-plugins.asciidoc[leveloffset=+1] +include::migrating-legacy-plugins-examples.asciidoc[leveloffset=+1] include::external-plugin-functional-tests.asciidoc[leveloffset=+1] - include::external-plugin-localization.asciidoc[leveloffset=+1] +include::testing-kibana-plugin.asciidoc[leveloffset=+1] diff --git a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc new file mode 100644 index 00000000000000..abf51bb3378b72 --- /dev/null +++ b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc @@ -0,0 +1,1186 @@ +[[migrating-legacy-plugins-examples]] +== Migration Examples + +This document is a list of examples of how to migrate plugin code from +legacy APIs to their {kib} Platform equivalents. + +[[config-migration]] +=== Configuration +==== Declaring config schema + +Declaring the schema of your configuration fields is similar to the +Legacy Platform, but uses the `@kbn/config-schema` package instead of +Joi. This package has full TypeScript support out-of-the-box. + +*Legacy config schema* +[source,typescript] +---- +import Joi from 'joi'; + +new kibana.Plugin({ + config() { + return Joi.object({ + enabled: Joi.boolean().default(true), + defaultAppId: Joi.string().default('home'), + index: Joi.string().default('.kibana'), + disableWelcomeScreen: Joi.boolean().default(false), + autocompleteTerminateAfter: Joi.number().integer().min(1).default(100000), + }) + } +}); +---- + +*{kib} Platform equivalent* +[source,typescript] +---- +import { schema, TypeOf } from '@kbn/config-schema'; + +export const config = { + schema: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + defaultAppId: schema.string({ defaultValue: true }), + index: schema.string({ defaultValue: '.kibana' }), + disableWelcomeScreen: schema.boolean({ defaultValue: false }), + autocompleteTerminateAfter: schema.duration({ min: 1, defaultValue: 100000 }), + }) +}; + +// @kbn/config-schema is written in TypeScript, so you can use your schema +// definition to create a type to use in your plugin code. +export type MyPluginConfig = TypeOf; +---- + +==== Using {kib} config in a new plugin + +After setting the config schema for your plugin, you might want to read +configuration values from your plugin. It is provided as part of the +{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md[PluginInitializerContext] +in the _constructor_ of the plugin: + +*plugins/my_plugin/(public|server)/index.ts* +[source,typescript] +---- +import type { PluginInitializerContext } from 'kibana/server'; +import { MyPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new MyPlugin(initializerContext); +} +---- + +*plugins/my_plugin/(public|server)/plugin.ts* +[source,typescript] +---- +import type { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { CoreSetup, Logger, Plugin, PluginInitializerContext, PluginName } from 'kibana/server'; +import type { MyPluginConfig } from './config'; + +export class MyPlugin implements Plugin { + private readonly config$: Observable; + private readonly log: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.log = initializerContext.logger.get(); + this.config$ = initializerContext.config.create(); + } + + public async setup(core: CoreSetup, deps: Record) { + const isEnabled = await this.config$.pipe(first()).toPromise(); + } +} +---- + +Additionally, some plugins need to access the runtime env configuration. + +[source,typescript] +---- +export class MyPlugin implements Plugin { + public async setup(core: CoreSetup, deps: Record) { + const { mode: { dev }, packageInfo: { version } } = this.initializerContext.env + } +---- + +=== Creating a {kib} Platform plugin + +For example, if you want to move the legacy `demoplugin` plugin's +configuration to the {kib} Platform, you could create the {kib} Platform plugin with the +same name in `plugins/demoplugin` with the following files: + +*plugins/demoplugin/kibana.json* +[source,json5] +---- +{ + "id": "demoplugin", + "server": true +} +---- + +*plugins/demoplugin/server/index.ts* +[source,typescript] +---- +import { schema, TypeOf } from '@kbn/config-schema'; +import type { PluginInitializerContext } from 'kibana/server'; +import { DemoPlugin } from './plugin'; + +export const config = { + schema: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }); +} + +export const plugin = (initContext: PluginInitializerContext) => new DemoPlugin(initContext); + +export type DemoPluginConfig = TypeOf; +export { DemoPluginSetup } from './plugin'; +---- + +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +import type { PluginInitializerContext, Plugin, CoreSetup } from 'kibana/server'; +import type { DemoPluginConfig } from '.'; +export interface DemoPluginSetup {}; + +export class DemoPlugin implements Plugin { + constructor(private readonly initContext: PluginInitializerContext) {} + + public setup(core: CoreSetup) { + return {}; + } + + public start() {} + public stop() {} +} +---- + +[[http-routes-migration]] +=== HTTP Routes + +In the legacy platform, plugins have direct access to the Hapi `server` +object, which gives full access to all of Hapi’s API. In the New +Platform, plugins have access to the +{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md[HttpServiceSetup] +interface, which is exposed via the +{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.coresetup.md[CoreSetup] +object injected into the `setup` method of server-side plugins. + +This interface has a different API with slightly different behaviors. + +* All input (body, query parameters, and URL parameters) must be +validated using the `@kbn/config-schema` package. If no validation +schema is provided, these values will be empty objects. +* All exceptions thrown by handlers result in 500 errors. If you need a +specific HTTP error code, catch any exceptions in your handler and +construct the appropriate response using the provided response factory. +While you can continue using the `Boom` module internally in your +plugin, the framework does not have native support for converting Boom +exceptions into HTTP responses. + +Migrate legacy route registration: +*legacy/plugins/demoplugin/index.ts* +[source,typescript] +---- +import Joi from 'joi'; + +new kibana.Plugin({ + init(server) { + server.route({ + path: '/api/demoplugin/search', + method: 'POST', + options: { + validate: { + payload: Joi.object({ + field1: Joi.string().required(), + }), + } + }, + handler(req, h) { + return { message: `Received field1: ${req.payload.field1}` }; + } + }); + } +}); +---- +to the {kib} platform format: +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +import { schema } from '@kbn/config-schema'; +import type { CoreSetup } from 'kibana/server'; + +export class DemoPlugin { + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + router.post( + { + path: '/api/demoplugin/search', + validate: { + body: schema.object({ + field1: schema.string(), + }), + } + }, + (context, req, res) => { + return res.ok({ + body: { + message: `Received field1: ${req.body.field1}` + } + }); + } + ) + } +} +---- + +If your plugin still relies on throwing Boom errors from routes, you can +use the `router.handleLegacyErrors` as a temporary solution until error +migration is complete: + +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +import { schema } from '@kbn/config-schema'; +import { CoreSetup } from 'kibana/server'; +import Boom from 'boom'; + +export class DemoPlugin { + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + router.post( + { + path: '/api/demoplugin/search', + validate: { + body: schema.object({ + field1: schema.string(), + }), + } + }, + router.handleLegacyErrors((context, req, res) => { + throw Boom.notFound('not there'); // will be converted into proper Platform error + }) + ) + } +} +---- + +=== Accessing Services + +Services in the Legacy Platform were typically available via methods on +either `server.plugins.*`, `server.*`, or `req.*`. In the {kib} Platform, +all services are available via the `context` argument to the route +handler. The type of this argument is the +{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md[RequestHandlerContext]. +The APIs available here will include all Core services and any services registered by plugins this plugin depends on. + +*legacy/plugins/demoplugin/index.ts* +[source,typescript] +---- +new kibana.Plugin({ + init(server) { + const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); + + server.route({ + path: '/api/my-plugin/my-route', + method: 'POST', + async handler(req, h) { + const results = await callWithRequest(req, 'search', query); + return { results }; + } + }); + } +}); +---- + +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +export class DemoPlugin { + public setup(core) { + const router = core.http.createRouter(); + router.post( + { + path: '/api/my-plugin/my-route', + }, + async (context, req, res) => { + const results = await context.core.elasticsearch.client.asCurrentUser.search(query); + return res.ok({ + body: { results } + }); + } + ) + } +} +---- + +=== Migrating Hapi pre-handlers + +In the Legacy Platform, routes could provide a `pre` option in their +config to register a function that should be run before the route +handler. These `pre` handlers allow routes to share some business +logic that may do some pre-work or validation. In {kib}, these are +often used for license checks. + +The {kib} Platform’s HTTP interface does not provide this +functionality. However, it is simple enough to port over using +a higher-order function that can wrap the route handler. + +==== Simple example + +In this simple example, a pre-handler is used to either abort the +request with an error or continue as normal. This is a simple +`gate-keeping` pattern. + +[source,typescript] +---- +// Legacy pre-handler +const licensePreRouting = (request) => { + const licenseInfo = getMyPluginLicenseInfo(request.server.plugins.xpack_main); + if (!licenseInfo.isOneOf(['gold', 'platinum', 'trial'])) { + throw Boom.forbidden(`You don't have the right license for MyPlugin!`); + } +} + +server.route({ + method: 'GET', + path: '/api/my-plugin/do-something', + config: { + pre: [{ method: licensePreRouting }] + }, + handler: (req) => { + return doSomethingInteresting(); + } +}) +---- + +In the {kib} Platform, the same functionality can be achieved by +creating a function that takes a route handler (or factory for a route +handler) as an argument and either successfully invokes it or +returns an error response. + +This a `high-order handler` similar to the `high-order +component` pattern common in the React ecosystem. + +[source,typescript] +---- +// Kibana Platform high-order handler +const checkLicense = ( + handler: RequestHandler +): RequestHandler => { + return (context, req, res) => { + const licenseInfo = getMyPluginLicenseInfo(context.licensing.license); + + if (licenseInfo.hasAtLeast('gold')) { + return handler(context, req, res); + } else { + return res.forbidden({ body: `You don't have the right license for MyPlugin!` }); + } + } +} + +router.get( + { path: '/api/my-plugin/do-something', validate: false }, + checkLicense(async (context, req, res) => { + const results = doSomethingInteresting(); + return res.ok({ body: results }); + }), +) +---- + +==== Full Example + +In some cases, the route handler may need access to data that the +pre-handler retrieves. In this case, you can utilize a handler _factory_ +rather than a raw handler. + +[source,typescript] +---- +// Legacy pre-handler +const licensePreRouting = (request) => { + const licenseInfo = getMyPluginLicenseInfo(request.server.plugins.xpack_main); + if (licenseInfo.isOneOf(['gold', 'platinum', 'trial'])) { + // In this case, the return value of the pre-handler is made available on + // whatever the 'assign' option is in the route config. + return licenseInfo; + } else { + // In this case, the route handler is never called and the user gets this + // error message + throw Boom.forbidden(`You don't have the right license for MyPlugin!`); + } +} + +server.route({ + method: 'GET', + path: '/api/my-plugin/do-something', + config: { + pre: [{ method: licensePreRouting, assign: 'licenseInfo' }] + }, + handler: (req) => { + const licenseInfo = req.pre.licenseInfo; + return doSomethingInteresting(licenseInfo); + } +}) +---- + +In many cases, it may be simpler to duplicate the function call to +retrieve the data again in the main handler. In other cases, you +can utilize a handler _factory_ rather than a raw handler as the +argument to your high-order handler. This way, the high-order handler can +pass arbitrary arguments to the route handler. + +[source,typescript] +---- +// Kibana Platform high-order handler +const checkLicense = ( + handlerFactory: (licenseInfo: MyPluginLicenseInfo) => RequestHandler +): RequestHandler => { + return (context, req, res) => { + const licenseInfo = getMyPluginLicenseInfo(context.licensing.license); + + if (licenseInfo.hasAtLeast('gold')) { + const handler = handlerFactory(licenseInfo); + return handler(context, req, res); + } else { + return res.forbidden({ body: `You don't have the right license for MyPlugin!` }); + } + } +} + +router.get( + { path: '/api/my-plugin/do-something', validate: false }, + checkLicense(licenseInfo => async (context, req, res) => { + const results = doSomethingInteresting(licenseInfo); + return res.ok({ body: results }); + }), +) +---- + +=== Chrome + +In the Legacy Platform, the `ui/chrome` import contained APIs for a very +wide range of features. In the {kib} Platform, some of these APIs have +changed or moved elsewhere. See <>. + +==== Updating an application navlink + +In the legacy platform, the navlink could be updated using +`chrome.navLinks.update`. + +[source,typescript] +---- +uiModules.get('xpack/ml').run(() => { + const showAppLink = xpackInfo.get('features.ml.showLinks', false); + const isAvailable = xpackInfo.get('features.ml.isAvailable', false); + + const navLinkUpdates = { + // hide by default, only show once the xpackInfo is initialized + hidden: !showAppLink, + disabled: !showAppLink || (showAppLink && !isAvailable), + }; + + npStart.core.chrome.navLinks.update('ml', navLinkUpdates); +}); +---- + +In the {kib} Platform, navlinks should not be updated directly. Instead, +it is now possible to add an `updater` when registering an application +to change the application or the navlink state at runtime. + +[source,typescript] +---- +// my_plugin has a required dependencie to the `licensing` plugin +interface MyPluginSetupDeps { + licensing: LicensingPluginSetup; +} + +export class MyPlugin implements Plugin { + setup({ application }, { licensing }: MyPluginSetupDeps) { + const updater$ = licensing.license$.pipe( + map(license => { + const { hidden, disabled } = calcStatusFor(license); + if (hidden) return { navLinkStatus: AppNavLinkStatus.hidden }; + if (disabled) return { navLinkStatus: AppNavLinkStatus.disabled }; + return { navLinkStatus: AppNavLinkStatus.default }; + }) + ); + + application.register({ + id: 'my-app', + title: 'My App', + updater$, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } +---- + +=== Chromeless Applications + +In {kib}, a `chromeless` application is one where the primary {kib} +UI components such as header or navigation can be hidden. In the legacy +platform, these were referred to as `hidden` applications and were set +via the `hidden` property in a {kib} plugin. Chromeless applications +are also not displayed in the left navbar. + +To mark an application as chromeless, specify `chromeless: false` when +registering your application to hide the chrome UI when the application +is mounted: + +[source,typescript] +---- +application.register({ + id: 'chromeless', + chromeless: true, + async mount(context, params) { + /* ... */ + }, +}); +---- + +If you wish to render your application at a route that does not follow +the `/app/${appId}` pattern, this can be done via the `appRoute` +property. Doing this currently requires you to register a server route +where you can return a bootstrapped HTML page for your application +bundle. + +[source,typescript] +---- +application.register({ + id: 'chromeless', + appRoute: '/chromeless', + chromeless: true, + async mount(context, params) { + /* ... */ + }, +}); +---- + +[[render-html-migration]] +=== Render HTML Content + +You can return a blank HTML page bootstrapped with the core application +bundle from an HTTP route handler via the `httpResources` service. You +may wish to do this if you are rendering a chromeless application with a +custom application route or have other custom rendering needs. + +[source,typescript] +---- +httpResources.register( + { path: '/chromeless', validate: false }, + (context, request, response) => { + //... some logic + return response.renderCoreApp(); + } +); +---- + +You can also exclude user data from the bundle metadata. User +data comprises all UI Settings that are _user provided_, then injected +into the page. You may wish to exclude fetching this data if not +authorized or to slim the page size. + +[source,typescript] +---- +httpResources.register( + { path: '/', validate: false, options: { authRequired: false } }, + (context, request, response) => { + //... some logic + return response.renderAnonymousCoreApp(); + } +); +---- + +[[saved-objects-migration]] +=== Saved Objects types + +In the legacy platform, saved object types were registered using static +definitions in the `uiExports` part of the plugin manifest. + +In the {kib} Platform, all these registrations are performed +programmatically during your plugin’s `setup` phase, using the core +`savedObjects`’s `registerType` setup API. + +The most notable difference is that in the {kib} Platform, the type +registration is performed in a single call to `registerType`, passing a +new `SavedObjectsType` structure that is a superset of the legacy +`schema`, `migrations` `mappings` and `savedObjectsManagement`. + +==== Concrete example + +Suppose you have the following in a legacy plugin: + +*legacy/plugins/demoplugin/index.ts* +[source,js] +---- +import mappings from './mappings.json'; +import { migrations } from './migrations'; + +new kibana.Plugin({ + init(server){ + // [...] + }, + uiExports: { + mappings, + migrations, + savedObjectSchemas: { + 'first-type': { + isNamespaceAgnostic: true, + }, + 'second-type': { + isHidden: true, + }, + }, + savedObjectsManagement: { + 'first-type': { + isImportableAndExportable: true, + icon: 'myFirstIcon', + defaultSearchField: 'title', + getTitle(obj) { + return obj.attributes.title; + }, + getEditUrl(obj) { + return `/some-url/${encodeURIComponent(obj.id)}`; + }, + }, + 'second-type': { + isImportableAndExportable: false, + icon: 'mySecondIcon', + getTitle(obj) { + return obj.attributes.myTitleField; + }, + getInAppUrl(obj) { + return { + path: `/some-url/${encodeURIComponent(obj.id)}`, + uiCapabilitiesPath: 'myPlugin.myType.show', + }; + }, + }, + }, + }, +}) +---- + +*legacy/plugins/demoplugin/mappings.json* +[source,json] +---- +{ + "first-type": { + "properties": { + "someField": { + "type": "text" + }, + "anotherField": { + "type": "text" + } + } + }, + "second-type": { + "properties": { + "textField": { + "type": "text" + }, + "boolField": { + "type": "boolean" + } + } + } +} +---- +*legacy/plugins/demoplugin/migrations.js* +[source,js] +---- +export const migrations = { + 'first-type': { + '1.0.0': migrateFirstTypeToV1, + '2.0.0': migrateFirstTypeToV2, + }, + 'second-type': { + '1.5.0': migrateSecondTypeToV15, + } +} +---- + +To migrate this, you have to regroup the declaration per-type. + +First type: +*plugins/demoplugin/server/saved_objects/first_type.ts* +[source,typescript] +---- +import type { SavedObjectsType } from 'kibana/server'; + +export const firstType: SavedObjectsType = { + name: 'first-type', + hidden: false, + namespaceType: 'agnostic', + mappings: { + properties: { + someField: { + type: 'text', + }, + anotherField: { + type: 'text', + }, + }, + }, + migrations: { + '1.0.0': migrateFirstTypeToV1, + '2.0.0': migrateFirstTypeToV2, + }, + management: { + importableAndExportable: true, + icon: 'myFirstIcon', + defaultSearchField: 'title', + getTitle(obj) { + return obj.attributes.title; + }, + getEditUrl(obj) { + return `/some-url/${encodeURIComponent(obj.id)}`; + }, + }, +}; +---- + +Second type: +*plugins/demoplugin/server/saved_objects/second_type.ts* +[source,typescript] +---- +import type { SavedObjectsType } from 'kibana/server'; + +export const secondType: SavedObjectsType = { + name: 'second-type', + hidden: true, + namespaceType: 'single', + mappings: { + properties: { + textField: { + type: 'text', + }, + boolField: { + type: 'boolean', + }, + }, + }, + migrations: { + '1.5.0': migrateSecondTypeToV15, + }, + management: { + importableAndExportable: false, + icon: 'mySecondIcon', + getTitle(obj) { + return obj.attributes.myTitleField; + }, + getInAppUrl(obj) { + return { + path: `/some-url/${encodeURIComponent(obj.id)}`, + uiCapabilitiesPath: 'myPlugin.myType.show', + }; + }, + }, +}; +---- + +Registration in the plugin’s setup phase: +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +import { firstType, secondType } from './saved_objects'; + +export class DemoPlugin implements Plugin { + setup({ savedObjects }) { + savedObjects.registerType(firstType); + savedObjects.registerType(secondType); + } +} +---- + +==== Changes in structure compared to legacy + +The {kib} Platform `registerType` expected input is very close to the legacy format. +However, there are some minor changes: + +* The `schema.isNamespaceAgnostic` property has been renamed: +`SavedObjectsType.namespaceType`. It no longer accepts a boolean but +instead an enum of `single`, `multiple`, or `agnostic` (see +{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md[SavedObjectsNamespaceType]). +* The `schema.indexPattern` was accepting either a `string` or a +`(config: LegacyConfig) => string`. `SavedObjectsType.indexPattern` only +accepts a string, as you can access the configuration during your +plugin’s setup phase. +* The `savedObjectsManagement.isImportableAndExportable` property has +been renamed: `SavedObjectsType.management.importableAndExportable`. +* The migration function signature has changed: In legacy, it used to be +[source,typescript] +---- +`(doc: SavedObjectUnsanitizedDoc, log: SavedObjectsMigrationLogger) => SavedObjectUnsanitizedDoc;` +---- +In {kib} Platform, it is +[source,typescript] +---- +`(doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => SavedObjectUnsanitizedDoc;` +---- + +With context being: + +[source,typescript] +---- +export interface SavedObjectMigrationContext { + log: SavedObjectsMigrationLogger; +} +---- + +The changes is very minor though. The legacy migration: + +[source,js] +---- +const migration = (doc, log) => {...} +---- + +Would be converted to: + +[source,typescript] +---- +const migration: SavedObjectMigrationFn = (doc, { log }) => {...} +---- + +=== UiSettings + +UiSettings defaults registration performed during `setup` phase via +`core.uiSettings.register` API. + +*legacy/plugins/demoplugin/index.js* +[source,js] +---- +uiExports: { + uiSettingDefaults: { + 'my-plugin:my-setting': { + name: 'just-work', + value: true, + description: 'make it work', + category: ['my-category'], + }, + } +} +---- + +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +setup(core: CoreSetup){ + core.uiSettings.register({ + 'my-plugin:my-setting': { + name: 'just-work', + value: true, + description: 'make it work', + category: ['my-category'], + schema: schema.boolean(), + }, + }) +} +---- + +=== Elasticsearch client + +The new elasticsearch client is a thin wrapper around +`@elastic/elasticsearch`’s `Client` class. Even if the API is quite +close to the legacy client {kib} was previously using, there are some +subtle changes to take into account during migration. + +https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html[Official +client documentation] + +==== Client API Changes + +Refer to the +https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/breaking-changes.html[Breaking +changes list] for more information about the changes between the legacy +and new client. + +The most significant changes on the Kibana side for the consumers are the following: + +===== User client accessor +Internal /current user client accessors has been renamed and are now +properties instead of functions: +** `callAsInternalUser('ping')` -> `asInternalUser.ping()` +** `callAsCurrentUser('ping')` -> `asCurrentUser.ping()` +* the API now reflects the `Client`’s instead of leveraging the +string-based endpoint names the `LegacyAPICaller` was using. + +Before: + +[source,typescript] +---- +const body = await client.callAsInternalUser('indices.get', { index: 'id' }); +---- + +After: + +[source,typescript] +---- +const { body } = await client.asInternalUser.indices.get({ index: 'id' }); +---- + +===== Response object +Calling any ES endpoint now returns the whole response object instead +of only the body payload. + +Before: + +[source,typescript] +---- +const body = await legacyClient.callAsInternalUser('get', { id: 'id' }); +---- + +After: + +[source,typescript] +---- +const { body } = await client.asInternalUser.get({ id: 'id' }); +---- + +Note that more information from the ES response is available: + +[source,typescript] +---- +const { + body, // response payload + statusCode, // http status code of the response + headers, // response headers + warnings, // warnings returned from ES + meta // meta information about the request, such as request parameters, number of attempts and so on +} = await client.asInternalUser.get({ id: 'id' }); +---- + +===== Response Type +All API methods are now generic to allow specifying the response body. +type + +Before: + +[source,typescript] +---- +const body: GetResponse = await legacyClient.callAsInternalUser('get', { id: 'id' }); +---- + +After: + +[source,typescript] +---- +// body is of type `GetResponse` +const { body } = await client.asInternalUser.get({ id: 'id' }); +// fallback to `Record` if unspecified +const { body } = await client.asInternalUser.get({ id: 'id' }); +---- + +The new client doesn’t provide exhaustive typings for the response +object yet. You might have to copy response type definitions from the +Legacy Elasticsearch library until the additional announcements. + +[source,typescript] +---- +// Kibana provides a few typings for internal purposes +import type { SearchResponse } from 'kibana/server'; +type SearchSource = {...}; +type SearchBody = SearchResponse; +const { body } = await client.search(...); +interface Info {...} +const { body } = await client.info(...); +---- + +===== Errors +The returned error types changed. + +There are no longer specific errors for every HTTP status code (such as +`BadRequest` or `NotFound`). A generic `ResponseError` with the specific +`statusCode` is thrown instead. + +Before: + +[source,typescript] +---- +import { errors } from 'elasticsearch'; +try { + await legacyClient.callAsInternalUser('ping'); +} catch(e) { + if(e instanceof errors.NotFound) { + // do something + } + if(e.status === 401) {} +} +---- + +After: + +[source,typescript] +---- +import { errors } from '@elastic/elasticsearch'; +try { + await client.asInternalUser.ping(); +} catch(e) { + if(e instanceof errors.ResponseError && e.statusCode === 404) { + // do something + } + // also possible, as all errors got a name property with the name of the class, + // so this slightly better in term of performances + if(e.name === 'ResponseError' && e.statusCode === 404) { + // do something + } + if(e.statusCode === 401) {...} +} +---- + +===== Parameter naming format +The parameter property names changed from camelCase to snake_case + +Even if technically, the JavaScript client accepts both formats, the +TypeScript definitions are only defining snake_case properties. + +Before: + +[source,typescript] +---- +legacyClient.callAsCurrentUser('get', { + id: 'id', + storedFields: ['some', 'fields'], +}) +---- + +After: + +[source,typescript] +---- +client.asCurrentUser.get({ + id: 'id', + stored_fields: ['some', 'fields'], +}) +---- + +===== Request abortion +The request abortion API changed + +All promises returned from the client API calls now have an `abort` +method that can be used to cancel the request. + +Before: + +[source,typescript] +---- +const controller = new AbortController(); +legacyClient.callAsCurrentUser('ping', {}, { + signal: controller.signal, +}) +// later +controller.abort(); +---- + +After: + +[source,typescript] +---- +const request = client.asCurrentUser.ping(); +// later +request.abort(); +---- + +===== Headers +It is now possible to override headers when performing specific API +calls. + +Note that doing so is strongly discouraged due to potential side effects +with the ES service internal behavior when scoping as the internal or as +the current user. + +[source,typescript] +---- +const request = client.asCurrentUser.ping({}, { + headers: { + authorization: 'foo', + custom: 'bar', + } +}); +---- + +===== Functional tests +Functional tests are subject to migration to the new client as well. + +Before: + +[source,typescript] +---- +const client = getService('legacyEs'); +---- + +After: + +[source,typescript] +---- +const client = getService('es'); +---- + +==== Accessing the client from a route handler + +Apart from the API format change, accessing the client from within a +route handler did not change. As it was done for the legacy client, a +preconfigured <> bound to an incoming request is accessible using +the `core` context provider: + +[source,typescript] +---- +router.get( + { + path: '/my-route', + }, + async (context, req, res) => { + const { client } = context.core.elasticsearch; + // call as current user + const res = await client.asCurrentUser.ping(); + // call as internal user + const res2 = await client.asInternalUser.search(options); + return res.ok({ body: 'ok' }); + } +); +---- + +==== Creating a custom client + +Note that the `plugins` option is no longer available on the new +client. As the API is now exhaustive, adding custom endpoints using +plugins should no longer be necessary. + +The API to create custom clients did not change much: + +Before: + +[source,typescript] +---- +const customClient = coreStart.elasticsearch.legacy.createClient('my-custom-client', customConfig); +// do something with the client, such as +await customClient.callAsInternalUser('ping'); +// custom client are closable +customClient.close(); +---- + +After: + +[source,typescript] +---- +const customClient = coreStart.elasticsearch.createClient('my-custom-client', customConfig); +// do something with the client, such as +await customClient.asInternalUser.ping(); +// custom client are closable +customClient.close(); +---- + +If, for any reasons, you still need to reach an endpoint not listed on +the client API, using `request.transport` is still possible: + +[source,typescript] +---- +const { body } = await client.asCurrentUser.transport.request({ + method: 'get', + path: '/my-custom-endpoint', + body: { my: 'payload'}, + querystring: { param: 'foo' } +}) +---- diff --git a/docs/developer/plugin/migrating-legacy-plugins.asciidoc b/docs/developer/plugin/migrating-legacy-plugins.asciidoc new file mode 100644 index 00000000000000..337d02b11ee916 --- /dev/null +++ b/docs/developer/plugin/migrating-legacy-plugins.asciidoc @@ -0,0 +1,608 @@ +[[migrating-legacy-plugins]] +== Migrating legacy plugins to the {kib} Platform + +[IMPORTANT] +============================================== +In {kib} 7.10, support for legacy-style {kib} plugins was completely removed. +Moving forward, all plugins must be built on the new {kib} Platform Plugin API. +This guide is intended to assist plugin authors in migrating their legacy plugin +to the {kib} Platform Plugin API. +============================================== + +Make no mistake, it is going to take a lot of work to move certain +plugins to the {kib} Platform. + +The goal of this document is to guide developers through the recommended +process of migrating at a high level. Every plugin is different, so +developers should tweak this plan based on their unique requirements. + +First, we recommend you read <> to get an overview +of how plugins work in the {kib} Platform. Then continue here to follow our +generic plan of action that can be applied to any legacy plugin. + +=== Challenges to overcome with legacy plugins + +{kib} Platform plugins have an identical architecture in the browser and on +the server. Legacy plugins have one architecture that they use in the +browser and an entirely different architecture that they use on the +server. + +This means that there are unique sets of challenges for migrating to the +{kib} Platform, depending on whether the legacy plugin code is on the +server or in the browser. + +==== Challenges on the server + +The general architecture of legacy server-side code is similar to +the {kib} Platform architecture in one important way: most legacy +server-side plugins define an `init` function where the bulk of their +business logic begins, and they access both `core` and +`plugin-provided` functionality through the arguments given to `init`. +Rarely does legacy server-side code share stateful services via import +statements. + +Although not exactly the same, legacy plugin `init` functions behave +similarly today as {kib} Platform `setup` functions. `KbnServer` also +exposes an `afterPluginsInit` method, which behaves similarly to `start`. +There is no corresponding legacy concept of `stop`. + +Despite their similarities, server-side plugins pose a formidable +challenge: legacy core and plugin functionality is retrieved from either +the hapi.js `server` or `request` god objects. Worse, these objects are +often passed deeply throughout entire plugins, which directly couples +business logic with hapi. And the worst of it all is, these objects are +mutable at any time. + +The key challenge to overcome with legacy server-side plugins will +decoupling from hapi. + +==== Challenges in the browser + +The legacy plugin system in the browser is fundamentally incompatible +with the {kib} Platform. There is no client-side plugin definition. There +are no services that get passed to plugins at runtime. There really +isn’t even a concrete notion of `core`. + +When a legacy browser plugin needs to access functionality from another +plugin, say to register a UI section to render within another plugin, it +imports a stateful (global singleton) JavaScript module and performs +some sort of state mutation. Sometimes this module exists inside the +plugin itself, and it gets imported via the `plugin/` webpack alias. +Sometimes this module exists outside the context of plugins entirely and +gets imported via the `ui/` webpack alias. Neither of these concepts +exists in the {kib} Platform. + +Legacy browser plugins rely on the feature known as `uiExports/`, which +integrates directly with our build system to ensure that plugin code is +bundled together in such a way to enable that global singleton module +state. There is no corresponding feature in the {kib} Platform, and in +the fact we intend down the line to build {kib} Platform plugins as immutable +bundles that can not share state in this way. + +The key challenge to overcome with legacy browser-side plugins will be +converting all imports from `plugin/`, `ui/`, `uiExports`, and relative +imports from other plugins into a set of services that originate at +runtime during plugin initialization and get passed around throughout +the business logic of the plugin as function arguments. + +==== Plan of action + +To move a legacy plugin to the new plugin system, the +challenges on the server and in the browser must be addressed. + +The approach and level of effort varies significantly between server and +browser plugins, but at a high level, the approach is the same. + +First, decouple your plugin’s business logic from the dependencies that +are not exposed through the {kib} Platform, hapi.js, and Angular.js. Then +introduce plugin definitions that more accurately reflect how plugins +are defined in the {kib} Platform. Finally, replace the functionality you +consume from the core and other plugins with their {kib} Platform equivalents. + +Once those things are finished for any given plugin, it can officially +be switched to the new plugin system. + +=== Server-side plan of action + +Legacy server-side plugins access functionality from the core and other +plugins at runtime via function arguments, which is similar to how they +must be architected to use the new plugin system. This greatly +simplifies the plan of action for migrating server-side plugins. +The main challenge here is to de-couple plugin logic from hapi.js server and request objects. + +For migration examples, see <>. + +=== Browser-side plan of action + +It is generally a much greater challenge preparing legacy browser-side +code for the {kib} Platform than it is server-side, and as such there are +a few more steps. The level of effort here is proportional to the extent +to which a plugin is dependent on Angular.js. + +To complicate matters further, a significant amount of the business +logic in {kib} client-side code exists inside the `ui/public` +directory (aka ui modules), and all of that must be migrated as well. + +Because the usage of Angular and `ui/public` modules varies widely between +legacy plugins, there is no `one size fits all` solution to migrating +your browser-side code to the {kib} Platform. + +For migration examples, see <>. + +=== Frequently asked questions + +==== Do plugins need to be converted to TypeScript? + +No. That said, the migration process will require a lot of refactoring, +and TypeScript will make this dramatically easier and less risky. + +Although it's not strictly necessary, we encourage any plugin that exposes an extension point to do so +with first-class type support so downstream plugins that _are_ using +TypeScript can depend on those types. + +==== How can I avoid passing core services deeply within my UI component tree? + +Some core services are purely presentational, for example +`core.overlays.openModal()`, where UI +code does need access to these deeply within your application. However, +passing these services down as props throughout your application leads +to lots of boilerplate. To avoid this, you have three options: + +* Use an abstraction layer, like Redux, to decouple your UI code from +core (*this is the highly preferred option*). +* https://github.com/reduxjs/redux-thunk#injecting-a-custom-argument[redux-thunk] +and +https://redux-saga.js.org/docs/api/#createsagamiddlewareoptions[redux-saga] +already have ways to do this. +* Use React Context to provide these services to large parts of your +React tree. +* Create a high-order-component that injects core into a React +component. +* This would be a stateful module that holds a reference to core, but +provides it as props to components with a `withCore(MyComponent)` +interface. This can make testing components simpler. (Note: this module +cannot be shared across plugin boundaries, see above). +* Create a global singleton module that gets imported into each module +that needs it. This module cannot be shared across plugin +boundaries. +https://gist.github.com/epixa/06c8eeabd99da3c7545ab295e49acdc3[Example]. + +If you find that you need many different core services throughout your +application, this might indicate a problem in your code and could lead to pain down the +road. For instance, if you need access to an HTTP Client or +SavedObjectsClient in many places in your React tree, it’s likely that a +data layer abstraction (like Redux) could make developing your plugin +much simpler. + +Without such an abstraction, you will need to mock out core services +throughout your test suite and will couple your UI code very tightly to +core. However, if you can contain all of your integration points with +core to Redux middleware and reducers, you only need to mock core +services once and benefit from being able to change those integrations +with core in one place rather than many. This will become incredibly +handy when core APIs have breaking changes. + +==== How is the 'common' code shared on both the client and the server? + +There is no formal notion of `common` code that can safely be imported +from either client-side or server-side code. However, if a plugin author +wishes to maintain a set of code in their plugin in a single place and +then expose it to both server-side and client-side code, they can do so +by exporting the index files for both the `server` and `public` +directories. + +Plugins _should not_ ever import code from deeply inside another plugin +(e.g. `my_plugin/public/components`) or from other top-level directories +(e.g. `my_plugin/common/constants`) as these are not checked for breaking +changes and are considered unstable and subject to change at any time. +You can have other top-level directories like `my_plugin/common`, but +our tooling will not treat these as a stable API, and linter rules will +prevent importing from these directories _from outside the plugin_. + +The benefit of this approach is that the details of where code lives and +whether it is accessible in multiple runtimes is an implementation +detail of the plugin itself. A plugin consumer that is writing +client-side code only ever needs to concern themselves with the +client-side contracts being exposed, and the same can be said for +server-side contracts on the server. + +A plugin author, who decides some set of code should diverge from having +a single `common` definition, can now safely change the implementation +details without impacting downstream consumers. + +==== How do I find {kib} Platform services? + +Most of the utilities you used to build legacy plugins are available +in the {kib} Platform or {kib} Platform plugins. To help you find the new +home for new services, use the tables below to find where the {kib} +Platform equivalent lives. + +===== Client-side +====== Core services + +In client code, `core` can be imported in legacy plugins via the +`ui/new_platform` module. + +[[client-side-core-migration-table]] +[width="100%",cols="15%,85%",options="header",] +|=== +|Legacy Platform |{kib} Platform +|`chrome.addBasePath` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.ibasepath.md[`core.http.basePath.prepend`] + +|`chrome.breadcrumbs.set` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.chromestart.setbreadcrumbs.md[`core.chrome.setBreadcrumbs`] + +|`chrome.getUiSettingsClient` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.corestart.uisettings.md[`core.uiSettings`] + +|`chrome.helpExtension.set` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.chromestart.sethelpextension.md[`core.chrome.setHelpExtension`] + +|`chrome.setVisible` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.chromestart.setisvisible.md[`core.chrome.setIsVisible`] + +|`chrome.getInjected` +| Request Data with your plugin REST HTTP API. + +|`chrome.setRootTemplate` / `chrome.setRootController` +|Use application mounting via {kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md[`core.application.register`] + +|`chrome.navLinks.update` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.app.updater_.md[`core.appbase.updater`]. Use the `updater$` property when registering your application via +`core.application.register` + +|`import { recentlyAccessed } from 'ui/persisted_log'` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.chromerecentlyaccessed.md[`core.chrome.recentlyAccessed`] + +|`ui/capabilities` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.capabilities.md[`core.application.capabilities`] + +|`ui/documentation_links` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md[`core.docLinks`] + +|`ui/kfetch` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.httpsetup.md[`core.http`] + +|`ui/notify` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.notificationsstart.md[`core.notifications`] +and +{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.overlaystart.md[`core.overlays`]. Toast messages are in `notifications`, banners are in `overlays`. + +|`ui/routes` +|There is no global routing mechanism. Each app +{kib-repo}blob/{branch}/rfcs/text/0004_application_service_mounting.md#complete-example[configures +its own routing]. + +|`ui/saved_objects` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.savedobjectsstart.md[`core.savedObjects`] + +|`ui/doc_title` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.chromedoctitle.md[`core.chrome.docTitle`] + +|`uiExports/injectedVars` / `chrome.getInjected` +|<>. Can only be used to expose configuration properties +|=== + +_See also: +{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.corestart.md[Public’s +CoreStart API Docs]_ + +====== Plugins for shared application services + +In client code, we have a series of plugins that house shared +application services, which are not technically part of `core`, but are +often used in {kib} plugins. + +This table maps some of the most commonly used legacy items to their {kib} +Platform locations. For the API provided by {kib} Plugins see <>. + +[width="100%",cols="15,85",options="header"] +|=== +|Legacy Platform |{kib} Platform +|`import 'ui/apply_filters'` |N/A. Replaced by triggering an +{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.action_global_apply_filter.md[APPLY_FILTER_TRIGGER trigger]. Directive is deprecated. + +|`import 'ui/filter_bar'` +|`import { FilterBar } from 'plugins/data/public'`. Directive is deprecated. + +|`import 'ui/query_bar'` +|`import { QueryStringInput } from 'plugins/data/public'` {kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md[QueryStringInput]. Directives are deprecated. + +|`import 'ui/search_bar'` +|`import { SearchBar } from 'plugins/data/public'` {kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.datapublicpluginstartui.searchbar.md[SearchBar]. Directive is deprecated. + +|`import 'ui/kbn_top_nav'` +|`import { TopNavMenu } from 'plugins/navigation/public'`. Directive was removed. + +|`ui/saved_objects/saved_object_finder` +|`import { SavedObjectFinder } from 'plugins/saved_objects/public'` + +|`core_plugins/interpreter` +|{kib-repo}blob/{branch}/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.md[`plugins.data.expressions`] + +|`ui/courier` +|{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.datapublicpluginsetup.search.md[`plugins.data.search`] + +|`ui/agg_types` +|{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.aggs.md[`plugins.data.search.aggs`]. Most code is available for +static import. Stateful code is part of the `search` service. + +|`ui/embeddable` +|{kib-repo}blob/{branch}/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablesetup.md[`plugins.embeddables`] + +|`ui/filter_manager` +|`import { FilterManager } from 'plugins/data/public'` {kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filtermanager.md[`FilterManager`] + +|`ui/index_patterns` +|`import { IndexPatternsService } from 'plugins/data/public'` {kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.md[IndexPatternsService] + +|`import 'ui/management'` +|`plugins.management.sections`. Management plugin `setup` contract. + +|`import 'ui/registry/field_format_editors'` +|`plugins.indexPatternManagement.fieldFormatEditors` indexPatternManagement plugin `setup` contract. + +|`ui/registry/field_formats` +|{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformats.md[`plugins.data.fieldFormats`] + +|`ui/registry/feature_catalogue` +|`plugins.home.featureCatalogue.register` home plugin `setup` contract + +|`ui/registry/vis_types` +|`plugins.visualizations` + +|`ui/vis` +|`plugins.visualizations` + +|`ui/share` +|`plugins.share`. share plugin `start` contract. `showShareContextMenu` is now called +`toggleShareContextMenu`, `ShareContextMenuExtensionsRegistryProvider` +is now called `register` + +|`ui/vis/vis_factory` +|`plugins.visualizations` + +|`ui/vis/vis_filters` +|`plugins.visualizations.filters` + +|`ui/utils/parse_es_interval` +|`import { search: { aggs: { parseEsInterval } } } from 'plugins/data/public'`. `parseEsInterval`, `ParsedInterval`, `InvalidEsCalendarIntervalError`, +`InvalidEsIntervalFormatError` items were moved to the `Data Plugin` as +a static code +|=== + +===== Server-side + +====== Core services + +In server code, `core` can be accessed from either `server.newPlatform` +or `kbnServer.newPlatform`: + +[width="100%",cols="17, 83",options="header"] +|=== +|Legacy Platform |{kib} Platform +|`server.config()` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md[`initializerContext.config.create()`]. Must also define schema. See <> + +|`server.route` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.createrouter.md[`core.http.createRouter`]. See <>. + +|`server.renderApp()` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.rendercoreapp.md[`response.renderCoreApp()`]. See <>. + +|`server.renderAppWithDefaultConfig()` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderanonymouscoreapp.md[`response.renderAnonymousCoreApp()`]. See <>. + +|`request.getBasePath()` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.basepath.md[`core.http.basePath.get`] + +|`server.plugins.elasticsearch.getCluster('data')` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md[`context.core.elasticsearch.client`] + +|`server.plugins.elasticsearch.getCluster('admin')` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md[`context.core.elasticsearch.client`] + +|`server.plugins.elasticsearch.createCluster(...)` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.createclient.md[`core.elasticsearch.createClient`] + +|`server.savedObjects.setScopedSavedObjectsClientFactory` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md[`core.savedObjects.setClientFactoryProvider`] + +|`server.savedObjects.addScopedSavedObjectsClientWrapperFactory` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.addclientwrapper.md[`core.savedObjects.addClientWrapper`] + +|`server.savedObjects.getSavedObjectsRepository` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md[`core.savedObjects.createInternalRepository`] +{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.createscopedrepository.md[`core.savedObjects.createScopedRepository`] + +|`server.savedObjects.getScopedSavedObjectsClient` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.getscopedclient.md[`core.savedObjects.getScopedClient`] + +|`request.getSavedObjectsClient` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md[`context.core.savedObjects.client`] + +|`request.getUiSettingsService` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iuisettingsclient.md[`context.core.uiSettings.client`] + +|`kibana.Plugin.deprecations` +|<> and {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.md[`PluginConfigDescriptor.deprecations`]. Deprecations from {kib} Platform are not applied to legacy configuration + +|`kibana.Plugin.savedObjectSchemas` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`kibana.Plugin.mappings` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`]. Learn more in <>. + +|`kibana.Plugin.migrations` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`]. Learn more in <>. + +|`kibana.Plugin.savedObjectsManagement` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`]. Learn more in <>. +|=== + +_See also: +{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.coresetup.md[Server’s +CoreSetup API Docs]_ + +====== Plugin services + +[width="100%",cols="50%,50%",options="header",] +|=== +|Legacy Platform |{kib} Platform +|`xpack_main.registerFeature` +|{kib-repo}blob/{branch}/x-pack/plugins/features/server/plugin.ts[`plugins.features.registerKibanaFeature`] + +|`xpack_main.feature(pluginID).registerLicenseCheckResultsGenerator` +|{kib-repo}blob/{branch}/x-pack/plugins/licensing/README.md[`x-pack licensing plugin`] +|=== + +===== UI Exports + +The legacy platform used a set of `uiExports` to inject modules from +one plugin into other plugins. This mechanism is not necessary for the +{kib} Platform because _all plugins are executed on the page at once_, +though only one application is rendered at a time. + +This table shows where these uiExports have moved to in the {kib} +Platform. + +[width="100%",cols="15%,85%",options="header"] +|=== +|Legacy Platform |{kib} Platform +|`aliases` +|`N/A`. + +|`app` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md[`core.application.register`] + +|`canvas` +|{kib-repo}blob/{branch}/x-pack/plugins/canvas/README.md[Canvas plugin API] + +|`chromeNavControls` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.md[`core.chrome.navControls.register{Left,Right}`] + +|`docViews` +|{kib-repo}blob/{branch}/src/plugins/discover/public/[`discover.docViews.addDocView`] + +|`embeddableActions` +|{kib-repo}blob/{branch}/src/plugins/embeddable/README.asciidoc[`embeddable plugin`] + +|`embeddableFactories` +|{kib-repo}blob/{branch}/src/plugins/embeddable/README.asciidoc[`embeddable plugin`], {kib-repo}blob/{branch}/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.registerembeddablefactory.md[`embeddable.registerEmbeddableFactory`] + +|`fieldFormatEditors`, `fieldFormats` +|{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformats.md[`data.fieldFormats`] + +|`hacks` +|`N/A`. Just run the code in your plugin’s `start` method. + +|`home` +|{kib-repo}blob/{branch}/src/plugins/embeddable/README.asciidoc[`home plugin`] {kib-repo}blob/{branch}/src/plugins/home/public/services/feature_catalogue[`home.featureCatalogue.register`] + +|`indexManagement` +|{kib-repo}blob/{branch}/x-pack/plugins/index_management/README.md[`index management plugin`] + +|`injectDefaultVars` +|`N/A`. Plugins will only be able to allow config values for the frontend. See<> + +|`inspectorViews` +|{kib-repo}blob/{branch}/src/plugins/inspector/README.md[`inspector plugin`] + +|`interpreter` +|{kib-repo}blob/{branch}/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.md[`plugins.data.expressions`] + +|`links` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md[`core.application.register`] + +|`managementSections` +|{kib-repo}blob/{branch}/src/plugins/management/README.md[`plugins.management.sections.register`] + +|`mappings` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`migrations` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`navbarExtensions` +|`N/A`. Deprecated. + +|`savedObjectSchemas` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`savedObjectsManagement` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`savedObjectTypes` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`search` +|{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md[`data.search`] + +|`shareContextMenuExtensions` +|{kib-repo}blob/{branch}/src/plugins/share/README.md[`plugins.share`] + +|`taskDefinitions` +|{kib-repo}blob/{branch}/x-pack/plugins/task_manager/README.md[`taskManager plugin`] + +|`uiCapabilities` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md[`core.application.register`] + +|`uiSettingDefaults` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.uisettingsservicesetup.md[`core.uiSettings.register`] + +|`validations` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`visEditorTypes` +|{kib-repo}blob/{branch}/src/plugins/visualizations[`visualizations plugin`] + +|`visTypeEnhancers` +|{kib-repo}blob/{branch}/src/plugins/visualizations[`visualizations plugin`] + +|`visTypes` +|{kib-repo}blob/{branch}/src/plugins/visualizations[`visualizations plugin`] + +|`visualize` +|{kib-repo}blob/{branch}/src/plugins/visualize/README.md[`visualize plugin`] +|=== + +===== Plugin Spec + +[width="100%",cols="22%,78%",options="header",] +|=== +|Legacy Platform |{kib} Platform +|`id` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`manifest.id`] + +|`require` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`manifest.requiredPlugins`] + +|`version` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`manifest.version`] + +|`kibanaVersion` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`manifest.kibanaVersion`] + +|`configPrefix` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`manifest.configPath`] + +|`config` +|<> + +|`deprecations` +|<> + +|`uiExports` +|`N/A`. Use platform & plugin public contracts + +|`publicDir` +|`N/A`. {kib} Platform serves static assets from `/public/assets` folder under `/plugins/{id}/assets/{path*}` URL. + +|`preInit`, `init`, `postInit` +|`N/A`. Use {kib} Platform <> +|=== + +=== See also + +For examples on how to migrate from specific legacy APIs, see <>. diff --git a/docs/developer/plugin/plugin-tooling.asciidoc b/docs/developer/plugin/plugin-tooling.asciidoc new file mode 100644 index 00000000000000..0b33a585863a4f --- /dev/null +++ b/docs/developer/plugin/plugin-tooling.asciidoc @@ -0,0 +1,50 @@ +[[plugin-tooling]] +== Plugin tooling + +[discrete] +[[automatic-plugin-generator]] +=== Automatic plugin generator + +We recommend that you kick-start your plugin by generating it with the {kib-repo}tree/{branch}/packages/kbn-plugin-generator[{kib} Plugin Generator]. Run the following in the {kib} repo, and you will be asked a couple of questions, see some progress bars, and have a freshly generated plugin ready for you to play with in {kib}'s `plugins` folder. + +["source","shell"] +----------- +node scripts/generate_plugin my_plugin_name # replace "my_plugin_name" with your desired plugin name +----------- + +[discrete] +=== Plugin location + +The {kib} directory must be named `kibana`, and your plugin directory should be located in the root of `kibana` in a `plugins` directory, for example: + +["source","shell"] +---- +. +└── kibana + └── plugins + ├── foo-plugin + └── bar-plugin +---- + +=== Build plugin distributable +WARNING: {kib} distributable is not shipped with `@kbn/optimizer` anymore. You need to pre-build your plugin for use in production. + +You can leverage {kib-repo}blob/{branch}/packages/kbn-plugin-helpers[@kbn/plugin-helpers] to build a distributable archive for your plugin. +The package transpiles the plugin code, adds polyfills, and links necessary js modules in the runtime. +You don't need to install the `plugin-helpers`: the `package.json` is already pre-configured if you created your plugin with `node scripts/generate_plugin` script. +To build your plugin run within your plugin folder: +["source","shell"] +----------- +yarn build +----------- +It will output a`zip` archive in `kibana/plugins/my_plugin_name/build/` folder. + +=== Install a plugin from archive +See <>. + +=== Run {kib} with your plugin in dev mode +Run `yarn start` in the {kib} root folder. Make sure {kib} found and bootstrapped your plugin: +["source","shell"] +----------- +[info][plugins-system] Setting up […] plugins: […, myPluginName, …] +----------- diff --git a/docs/developer/plugin/testing-kibana-plugin.asciidoc b/docs/developer/plugin/testing-kibana-plugin.asciidoc new file mode 100644 index 00000000000000..6e856d2e2578a7 --- /dev/null +++ b/docs/developer/plugin/testing-kibana-plugin.asciidoc @@ -0,0 +1,63 @@ +[[testing-kibana-plugin]] +== Testing {kib} Plugins +=== Writing tests +Learn about <>. + +=== Mock {kib} Core services in tests + +Core services already provide mocks to simplify testing and make sure +plugins always rely on valid public contracts: + +*my_plugin/server/plugin.test.ts* +[source,typescript] +---- +import { configServiceMock } from 'kibana/server/mocks'; + +const configService = configServiceMock.create(); +configService.atPath.mockReturnValue(config$); +… +const plugin = new MyPlugin({ configService }, …); +---- + +Or if you need to get the whole core `setup` or `start` contracts: + +*my_plugin/server/plugin.test.ts* +[source,typescript] +---- +import { coreMock } from 'kibana/public/mocks'; + +const coreSetup = coreMock.createSetup(); +coreSetup.uiSettings.get.mockImplementation((key: string) => { + … +}); +… +const plugin = new MyPlugin(coreSetup, ...); +---- + +=== Writing mocks for your plugin +Although it isn’t mandatory, we strongly recommended you export your +plugin mocks as well, in order for dependent plugins to use them in +tests. Your plugin mocks should be exported from the root `/server` and +`/public` directories in your plugin: + +*my_plugin/(server|public)/mocks.ts* +[source,typescript] +---- +const createSetupContractMock = () => { + const startContract: jest.Mocked= { + isValid: jest.fn(), + } + // here we already type check as TS infers to the correct type declared above + startContract.isValid.mockReturnValue(true); + return startContract; +} + +export const myPluginMocks = { + createSetup: createSetupContractMock, + createStart: … +} +---- + +Plugin mocks should consist of mocks for _public APIs only_: +`setup`, `start` & `stop` contracts. Mocks aren’t necessary for pure functions as +other plugins can call the original implementation in tests. diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.exporters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.exporters.md new file mode 100644 index 00000000000000..883dbcfe289cb2 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.exporters.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [exporters](./kibana-plugin-plugins-data-public.exporters.md) + +## exporters variable + +Signature: + +```typescript +exporters: { + datatableToCSV: typeof datatableToCSV; + CSV_MIME_TYPE: string; +} +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index bafcd8bdffff97..b8e45cde3c18b1 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -108,6 +108,7 @@ | [esFilters](./kibana-plugin-plugins-data-public.esfilters.md) | | | [esKuery](./kibana-plugin-plugins-data-public.eskuery.md) | | | [esQuery](./kibana-plugin-plugins-data-public.esquery.md) | | +| [exporters](./kibana-plugin-plugins-data-public.exporters.md) | | | [extractSearchSourceReferences](./kibana-plugin-plugins-data-public.extractsearchsourcereferences.md) | | | [fieldFormats](./kibana-plugin-plugins-data-public.fieldformats.md) | | | [fieldList](./kibana-plugin-plugins-data-public.fieldlist.md) | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index b886aafcfc00fa..2fd84730957b68 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "screenTitle" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "indexPatterns" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.exporters.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.exporters.md new file mode 100644 index 00000000000000..6fda400d09fd0c --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.exporters.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [exporters](./kibana-plugin-plugins-data-server.exporters.md) + +## exporters variable + +Signature: + +```typescript +exporters: { + datatableToCSV: typeof datatableToCSV; + CSV_MIME_TYPE: string; +} +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index 8957f6d0f06b4c..d9f14950be0e8e 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -76,6 +76,7 @@ | [esFilters](./kibana-plugin-plugins-data-server.esfilters.md) | | | [esKuery](./kibana-plugin-plugins-data-server.eskuery.md) | | | [esQuery](./kibana-plugin-plugins-data-server.esquery.md) | | +| [exporters](./kibana-plugin-plugins-data-server.exporters.md) | | | [fieldFormats](./kibana-plugin-plugins-data-server.fieldformats.md) | | | [indexPatterns](./kibana-plugin-plugins-data-server.indexpatterns.md) | | | [mergeCapabilitiesWithFields](./kibana-plugin-plugins-data-server.mergecapabilitieswithfields.md) | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md index e2a71a7badd4d6..77abcacd7704af 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md @@ -52,6 +52,7 @@ search: { siblingPipelineType: string; termsAggFilter: string[]; toAbsoluteDates: typeof toAbsoluteDates; + calcAutoIntervalLessThan: typeof calcAutoIntervalLessThan; }; getRequestInspectorStats: typeof getRequestInspectorStats; getResponseInspectorStats: typeof getResponseInspectorStats; diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md index fb6ba7ee2621ce..fcccd3f6b96181 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `ExpressionRenderHandler` class Signature: ```typescript -constructor(element: HTMLElement, { onRenderError }?: Partial); +constructor(element: HTMLElement, { onRenderError, renderMode }?: Partial); ``` ## Parameters @@ -17,5 +17,5 @@ constructor(element: HTMLElement, { onRenderError }?: PartialHTMLElement | | -| { onRenderError } | Partial<ExpressionRenderHandlerParams> | | +| { onRenderError, renderMode } | Partial<ExpressionRenderHandlerParams> | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md index 7f7d5792ba684d..12c663273bd8c9 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md @@ -14,7 +14,7 @@ export declare class ExpressionRenderHandler | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(element, { onRenderError })](./kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md) | | Constructs a new instance of the ExpressionRenderHandler class | +| [(constructor)(element, { onRenderError, renderMode })](./kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md) | | Constructs a new instance of the ExpressionRenderHandler class | ## Properties diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md index 2dfc67d2af5fa1..54eecad0deb506 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md @@ -21,6 +21,7 @@ export interface IExpressionLoaderParams | [disableCaching](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.disablecaching.md) | boolean | | | [inspectorAdapters](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.inspectoradapters.md) | Adapters | | | [onRenderError](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.onrendererror.md) | RenderErrorHandlerFnType | | +| [renderMode](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md) | RenderMode | | | [searchContext](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.searchcontext.md) | SerializableState | | | [searchSessionId](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.searchsessionid.md) | string | | | [uiState](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.uistate.md) | unknown | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md new file mode 100644 index 00000000000000..2986b81fc67c53 --- /dev/null +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) > [IExpressionLoaderParams](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md) > [renderMode](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md) + +## IExpressionLoaderParams.renderMode property + +Signature: + +```typescript +renderMode?: RenderMode; +``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md new file mode 100644 index 00000000000000..8cddec1a5359c7 --- /dev/null +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) > [IInterpreterRenderHandlers](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md) > [getRenderMode](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md) + +## IInterpreterRenderHandlers.getRenderMode property + +Signature: + +```typescript +getRenderMode: () => RenderMode; +``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md index ab0273be714022..a65e025451636b 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md @@ -16,6 +16,7 @@ export interface IInterpreterRenderHandlers | --- | --- | --- | | [done](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.done.md) | () => void | Done increments the number of rendering successes | | [event](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.event.md) | (event: any) => void | | +| [getRenderMode](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md) | () => RenderMode | | | [onDestroy](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.ondestroy.md) | (fn: () => void) => void | | | [reload](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.reload.md) | () => void | | | [uiState](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.uistate.md) | PersistedState | | diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md new file mode 100644 index 00000000000000..16db25ab244f69 --- /dev/null +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-server](./kibana-plugin-plugins-expressions-server.md) > [IInterpreterRenderHandlers](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md) > [getRenderMode](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md) + +## IInterpreterRenderHandlers.getRenderMode property + +Signature: + +```typescript +getRenderMode: () => RenderMode; +``` diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md index ccf6271f712b94..b1496386944fa0 100644 --- a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md @@ -16,6 +16,7 @@ export interface IInterpreterRenderHandlers | --- | --- | --- | | [done](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.done.md) | () => void | Done increments the number of rendering successes | | [event](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.event.md) | (event: any) => void | | +| [getRenderMode](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md) | () => RenderMode | | | [onDestroy](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.ondestroy.md) | (fn: () => void) => void | | | [reload](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.reload.md) | () => void | | | [uiState](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.uistate.md) | PersistedState | | diff --git a/docs/user/dashboard/tutorials.asciidoc b/docs/user/dashboard/tutorials.asciidoc index b04de5fd0da6f2..d3abb849af819a 100644 --- a/docs/user/dashboard/tutorials.asciidoc +++ b/docs/user/dashboard/tutorials.asciidoc @@ -76,8 +76,6 @@ Now that you've created your *Lens* visualization, add it to a <> set. @@ -98,7 +96,7 @@ line chart which shows the total number of documents across all your indices within the time range. [role="screenshot"] -image::visualize/images/vega_lite_default.png[] +image::visualize/images/vega_lite_default.png[Vega-Lite tutorial default visualization] The text editor contains a Vega-Lite spec written in https://hjson.github.io/[HJSON], which is similar to JSON but optimized for human editing. HJSON supports: @@ -134,7 +132,7 @@ Click "Update". The result is probably not what you expect. You should see a fla line with 0 results. You've only changed the index, so the difference must be the query is returning -no results. You can try the <>, +no results. You can try the <>, but intuition may be faster for this particular problem. In this case, the problem is that you are querying the field `@timestamp`, @@ -332,38 +330,29 @@ your spec: If you copy and paste that into your Vega-Lite spec, and click "Update", you will see a warning saying `Infinite extent for field "key": [Infinity, -Infinity]`. -Let's use our <> to understand why. +Let's use our <> to understand why. Vega-Lite generates data using the names `source_0` and `data_0`. `source_0` contains the results from the {es} query, and `data_0` contains the visually encoded results which are shown in the chart. To debug this problem, you need to compare both. -To look at the source, open the browser dev tools console and type -`VEGA_DEBUG.view.data('source_0')`. You will see: +To inspect data sets, go to *Inspect* and select *View: Vega debug*. You will see a menu with different data sources: -```js -[{ - doc_count: 454 - key: "Men's Clothing" - time_buckets: {buckets: Array(57)} - Symbol(vega_id): 12822 -}, ...] -``` +[role="screenshot"] +image::visualize/images/vega_lite_tutorial_3.png[Data set selector showing root, source_0, data_0, and marks] -To compare to the visually encoded data, open the browser dev tools console and type -`VEGA_DEBUG.view.data('data_0')`. You will see: +To look closer at the raw data in Vega, select the option for `source_0` in the dropdown: -```js -[{ - doc_count: 454 - key: NaN - time_buckets: {buckets: Array(57)} - Symbol(vega_id): 13879 -}] -``` +[role="screenshot"] +image::visualize/images/vega_lite_tutorial_4.png[Table for data_0 with columns key, doc_count and array of time_buckets] + +To compare to the visually encoded data, change the dropdown selection to `data_0`. You will see: + +[role="screenshot"] +image::visualize/images/vega_lite_tutorial_5.png[Table for data_0 where the key is NaN instead of a string] The issue seems to be that the `key` property is not being converted the right way, -which makes sense because the `key` is now `Men's Clothing` instead of a timestamp. +which makes sense because the `key` is now category (`Men's Clothing`, `Women's Clothing`, etc.) instead of a timestamp. To fix this, try updating the `encoding` of your Vega-Lite spec to: @@ -382,21 +371,13 @@ To fix this, try updating the `encoding` of your Vega-Lite spec to: } ``` -This will show more errors, and you can inspect `VEGA_DEBUG.view.data('data_0')` to +This will show more errors, so you need to debug. Click *Inspect*, switch the view to *Vega Debug*, and switch to look at the visually encoded data in `data_0` to understand why. This now shows: -```js -[{ - doc_count: 454 - key: "Men's Clothing" - time_buckets: {buckets: Array(57)} - time_buckets.buckets.doc_count: undefined - time_buckets.buckets.key: null - Symbol(vega_id): 14094 -}] -``` +[role="screenshot"] +image::visualize/images/vega_lite_tutorial_6.png[Table for data_0 showing that the column time_buckets.buckets.key is undefined] -It looks like the problem is that the `time_buckets` inner array is not being +It looks like the problem is that the `time_buckets.buckets` inner array is not being extracted by Vega. The solution is to use a Vega-lite https://vega.github.io/vega-lite/docs/flatten.html[flatten transformation], available in {kib} 7.9 and later. If using an older version of Kibana, the flatten transformation is available in Vega @@ -411,23 +392,10 @@ Add this section in between the `data` and `encoding` section: ``` This does not yet produce the results you expect. Inspect the transformed data -by typing `VEGA_DEBUG.view.data('data_0')` into the console again: +by selecting `data_0` in *Data sets* again: -```js -[{ - doc_count: 453 - key: "Men's Clothing" - time_bucket.buckets.doc_count: undefined - time_buckets: {buckets: Array(57)} - time_buckets.buckets: { - key_as_string: "2020-06-30T15:00:00.000Z", - key: 1593529200000, - doc_count: 2 - } - time_buckets.buckets.key: null - Symbol(vega_id): 21564 -}] -``` +[role="screenshot"] +image::visualize/images/vega_lite_tutorial_7.png[Table showing data_0 with multiple pages of results, but undefined values in the column time_buckets.buckets.key] The debug view shows `undefined` values where you would expect to see numbers, and the cause is that there are duplicate names which are confusing Vega-Lite. This can @@ -564,7 +532,9 @@ Now that you've enabled a selection, try moving the mouse around the visualizati and seeing the points respond to the nearest position: [role="screenshot"] -image::visualize/images/vega_lite_tutorial_2.png[] +image::visualize/images/vega_lite_tutorial_2.png[Vega-Lite tutorial selection enabled] + +The selection is controlled by a Vega signal, and can be viewed using the <>. The final result of this tutorial is this spec: @@ -683,8 +653,6 @@ The final result of this tutorial is this spec: [[vega-tutorial-update-kibana-filters-from-vega]] === Update {kib} filters from Vega -experimental[] - In this tutorial you will build an area chart in Vega using an {es} search query, and add a click handler and drag handler to update {kib} filters. This tutorial is not a full https://vega.github.io/vega/tutorials/[Vega tutorial], @@ -935,6 +903,7 @@ The first step is to add a new `signal` to track the X position of the cursor: }] } ``` +To learn more about inspecting signals, explore the <>. Now add a new `mark` to indicate the current cursor position: @@ -1756,4 +1725,4 @@ Customize and format the visualization using functions: image::images/timelion-conditional04.png[] {nbsp} -For additional information on Timelion conditional capabilities, go to https://www.elastic.co/blog/timeseries-if-then-else-with-timelion[I have but one .condition()]. \ No newline at end of file +For additional information on Timelion conditional capabilities, go to https://www.elastic.co/blog/timeseries-if-then-else-with-timelion[I have but one .condition()]. diff --git a/docs/visualize/images/vega_lite_tutorial_3.png b/docs/visualize/images/vega_lite_tutorial_3.png new file mode 100644 index 00000000000000..a294e02f078486 Binary files /dev/null and b/docs/visualize/images/vega_lite_tutorial_3.png differ diff --git a/docs/visualize/images/vega_lite_tutorial_4.png b/docs/visualize/images/vega_lite_tutorial_4.png new file mode 100644 index 00000000000000..e73a837fa816bb Binary files /dev/null and b/docs/visualize/images/vega_lite_tutorial_4.png differ diff --git a/docs/visualize/images/vega_lite_tutorial_5.png b/docs/visualize/images/vega_lite_tutorial_5.png new file mode 100644 index 00000000000000..d0c84fe76ba558 Binary files /dev/null and b/docs/visualize/images/vega_lite_tutorial_5.png differ diff --git a/docs/visualize/images/vega_lite_tutorial_6.png b/docs/visualize/images/vega_lite_tutorial_6.png new file mode 100644 index 00000000000000..486ef6c362438e Binary files /dev/null and b/docs/visualize/images/vega_lite_tutorial_6.png differ diff --git a/docs/visualize/images/vega_lite_tutorial_7.png b/docs/visualize/images/vega_lite_tutorial_7.png new file mode 100644 index 00000000000000..d2c83371b107bc Binary files /dev/null and b/docs/visualize/images/vega_lite_tutorial_7.png differ diff --git a/package.json b/package.json index 8e94e5277b8e3e..af80102641dbb4 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "**/prismjs": "1.22.0", "**/request": "^2.88.2", "**/trim": "0.0.3", - "**/typescript": "4.0.2" + "**/typescript": "4.1.2" }, "engines": { "node": "12.19.1", @@ -106,7 +106,7 @@ "@babel/runtime": "^7.11.2", "@elastic/datemath": "link:packages/elastic-datemath", "@elastic/elasticsearch": "7.10.0-rc.1", - "@elastic/ems-client": "7.10.0", + "@elastic/ems-client": "7.11.0", "@elastic/eui": "30.2.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", @@ -654,7 +654,7 @@ "file-loader": "^4.2.0", "file-saver": "^1.3.8", "formsy-react": "^1.1.5", - "geckodriver": "^1.20.0", + "geckodriver": "^1.21.0", "glob-watcher": "5.0.3", "graphql-code-generator": "^0.18.2", "graphql-codegen-add": "^0.18.2", @@ -827,7 +827,7 @@ "topojson-client": "3.0.0", "ts-loader": "^7.0.5", "tsd": "^0.13.1", - "typescript": "4.0.2", + "typescript": "4.1.2", "typescript-fsa": "^3.0.0", "typescript-fsa-reducers": "^1.2.1", "unlazy-loader": "^0.1.3", diff --git a/packages/kbn-es-archiver/package.json b/packages/kbn-es-archiver/package.json index 3cd07668635f1e..fed48440111581 100644 --- a/packages/kbn-es-archiver/package.json +++ b/packages/kbn-es-archiver/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/test": "link:../kbn-test" + "@kbn/test": "link:../kbn-test", + "@kbn/utils": "link:../kbn-utils" } } \ No newline at end of file diff --git a/packages/kbn-es-archiver/src/actions/edit.ts b/packages/kbn-es-archiver/src/actions/edit.ts index 1194637b1ff89e..9a270fd3820f05 100644 --- a/packages/kbn-es-archiver/src/actions/edit.ts +++ b/packages/kbn-es-archiver/src/actions/edit.ts @@ -23,8 +23,7 @@ import { createGunzip, createGzip, Z_BEST_COMPRESSION } from 'zlib'; import { promisify } from 'util'; import globby from 'globby'; import { ToolingLog } from '@kbn/dev-utils'; - -import { createPromiseFromStreams } from '../lib/streams'; +import { createPromiseFromStreams } from '@kbn/utils'; const unlinkAsync = promisify(Fs.unlink); diff --git a/packages/kbn-es-archiver/src/actions/load.ts b/packages/kbn-es-archiver/src/actions/load.ts index c2f5f18a07e9b7..11d47437126b0c 100644 --- a/packages/kbn-es-archiver/src/actions/load.ts +++ b/packages/kbn-es-archiver/src/actions/load.ts @@ -23,7 +23,7 @@ import { Readable } from 'stream'; import { ToolingLog, KbnClient } from '@kbn/dev-utils'; import { Client } from 'elasticsearch'; -import { createPromiseFromStreams, concatStreamProviders } from '../lib/streams'; +import { createPromiseFromStreams, concatStreamProviders } from '@kbn/utils'; import { isGzip, diff --git a/packages/kbn-es-archiver/src/actions/rebuild_all.ts b/packages/kbn-es-archiver/src/actions/rebuild_all.ts index 470a566a6eef05..8abc24d5270418 100644 --- a/packages/kbn-es-archiver/src/actions/rebuild_all.ts +++ b/packages/kbn-es-archiver/src/actions/rebuild_all.ts @@ -22,8 +22,7 @@ import { stat, Stats, rename, createReadStream, createWriteStream } from 'fs'; import { Readable, Writable } from 'stream'; import { fromNode } from 'bluebird'; import { ToolingLog } from '@kbn/dev-utils'; - -import { createPromiseFromStreams } from '../lib/streams'; +import { createPromiseFromStreams } from '@kbn/utils'; import { prioritizeMappings, readDirectory, diff --git a/packages/kbn-es-archiver/src/actions/save.ts b/packages/kbn-es-archiver/src/actions/save.ts index 84a0ce09936d09..60a04a6123c92c 100644 --- a/packages/kbn-es-archiver/src/actions/save.ts +++ b/packages/kbn-es-archiver/src/actions/save.ts @@ -22,8 +22,8 @@ import { createWriteStream, mkdirSync } from 'fs'; import { Readable, Writable } from 'stream'; import { Client } from 'elasticsearch'; import { ToolingLog } from '@kbn/dev-utils'; +import { createListStream, createPromiseFromStreams } from '@kbn/utils'; -import { createListStream, createPromiseFromStreams } from '../lib/streams'; import { createStats, createGenerateIndexRecordsStream, diff --git a/packages/kbn-es-archiver/src/actions/unload.ts b/packages/kbn-es-archiver/src/actions/unload.ts index ae23ef21fb79f5..915f0906eb0d30 100644 --- a/packages/kbn-es-archiver/src/actions/unload.ts +++ b/packages/kbn-es-archiver/src/actions/unload.ts @@ -22,8 +22,8 @@ import { createReadStream } from 'fs'; import { Readable, Writable } from 'stream'; import { Client } from 'elasticsearch'; import { ToolingLog, KbnClient } from '@kbn/dev-utils'; +import { createPromiseFromStreams } from '@kbn/utils'; -import { createPromiseFromStreams } from '../lib/streams'; import { isGzip, createStats, diff --git a/packages/kbn-es-archiver/src/cli.ts b/packages/kbn-es-archiver/src/cli.ts index 87df07fe865bdc..d65f5a5b23cd0d 100644 --- a/packages/kbn-es-archiver/src/cli.ts +++ b/packages/kbn-es-archiver/src/cli.ts @@ -228,7 +228,7 @@ export function runCli() { output: process.stdout, }); - await new Promise((resolveInput) => { + await new Promise((resolveInput) => { rl.question(`Press enter when you're done`, () => { rl.close(); resolveInput(); diff --git a/packages/kbn-es-archiver/src/lib/archives/__tests__/format.ts b/packages/kbn-es-archiver/src/lib/archives/__tests__/format.ts index 044a0e82d9df2e..91c38d0dd1438e 100644 --- a/packages/kbn-es-archiver/src/lib/archives/__tests__/format.ts +++ b/packages/kbn-es-archiver/src/lib/archives/__tests__/format.ts @@ -21,8 +21,7 @@ import Stream, { Readable, Writable } from 'stream'; import { createGunzip } from 'zlib'; import expect from '@kbn/expect'; - -import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '@kbn/utils'; import { createFormatArchiveStreams } from '../format'; diff --git a/packages/kbn-es-archiver/src/lib/archives/__tests__/parse.ts b/packages/kbn-es-archiver/src/lib/archives/__tests__/parse.ts index 25b8fe46a81fc1..deaea5cd4532e7 100644 --- a/packages/kbn-es-archiver/src/lib/archives/__tests__/parse.ts +++ b/packages/kbn-es-archiver/src/lib/archives/__tests__/parse.ts @@ -21,8 +21,7 @@ import Stream, { PassThrough, Readable, Writable, Transform } from 'stream'; import { createGzip } from 'zlib'; import expect from '@kbn/expect'; - -import { createConcatStream, createListStream, createPromiseFromStreams } from '../../streams'; +import { createConcatStream, createListStream, createPromiseFromStreams } from '@kbn/utils'; import { createParseArchiveStreams } from '../parse'; diff --git a/packages/kbn-es-archiver/src/lib/archives/format.ts b/packages/kbn-es-archiver/src/lib/archives/format.ts index 3cd698c3f82c30..74c9561407c8d8 100644 --- a/packages/kbn-es-archiver/src/lib/archives/format.ts +++ b/packages/kbn-es-archiver/src/lib/archives/format.ts @@ -21,7 +21,7 @@ import { createGzip, Z_BEST_COMPRESSION } from 'zlib'; import { PassThrough } from 'stream'; import stringify from 'json-stable-stringify'; -import { createMapStream, createIntersperseStream } from '../streams'; +import { createMapStream, createIntersperseStream } from '@kbn/utils'; import { RECORD_SEPARATOR } from './constants'; export function createFormatArchiveStreams({ gzip = false }: { gzip?: boolean } = {}) { diff --git a/packages/kbn-es-archiver/src/lib/archives/parse.ts b/packages/kbn-es-archiver/src/lib/archives/parse.ts index 9236a618aa01ad..65b01f38eb83e8 100644 --- a/packages/kbn-es-archiver/src/lib/archives/parse.ts +++ b/packages/kbn-es-archiver/src/lib/archives/parse.ts @@ -24,7 +24,7 @@ import { createSplitStream, createReplaceStream, createMapStream, -} from '../streams'; +} from '@kbn/utils'; import { RECORD_SEPARATOR } from './constants'; diff --git a/packages/kbn-es-archiver/src/lib/docs/__tests__/generate_doc_records_stream.ts b/packages/kbn-es-archiver/src/lib/docs/__tests__/generate_doc_records_stream.ts index 3c5fc742a6e104..074333eb6028fa 100644 --- a/packages/kbn-es-archiver/src/lib/docs/__tests__/generate_doc_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/docs/__tests__/generate_doc_records_stream.ts @@ -20,8 +20,7 @@ import sinon from 'sinon'; import expect from '@kbn/expect'; import { delay } from 'bluebird'; - -import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '@kbn/utils'; import { createGenerateDocRecordsStream } from '../generate_doc_records_stream'; import { Progress } from '../../progress'; diff --git a/packages/kbn-es-archiver/src/lib/docs/__tests__/index_doc_records_stream.ts b/packages/kbn-es-archiver/src/lib/docs/__tests__/index_doc_records_stream.ts index 2b8eac5c271229..ac85681610c6ce 100644 --- a/packages/kbn-es-archiver/src/lib/docs/__tests__/index_doc_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/docs/__tests__/index_doc_records_stream.ts @@ -19,8 +19,7 @@ import expect from '@kbn/expect'; import { delay } from 'bluebird'; - -import { createListStream, createPromiseFromStreams } from '../../streams'; +import { createListStream, createPromiseFromStreams } from '@kbn/utils'; import { Progress } from '../../progress'; import { createIndexDocRecordsStream } from '../index_doc_records_stream'; diff --git a/packages/kbn-es-archiver/src/lib/indices/__tests__/create_index_stream.ts b/packages/kbn-es-archiver/src/lib/indices/__tests__/create_index_stream.ts index 27c28b2229aeca..b1a83046f40d6b 100644 --- a/packages/kbn-es-archiver/src/lib/indices/__tests__/create_index_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/__tests__/create_index_stream.ts @@ -20,8 +20,7 @@ import expect from '@kbn/expect'; import sinon from 'sinon'; import Chance from 'chance'; - -import { createPromiseFromStreams, createConcatStream, createListStream } from '../../streams'; +import { createPromiseFromStreams, createConcatStream, createListStream } from '@kbn/utils'; import { createCreateIndexStream } from '../create_index_stream'; diff --git a/packages/kbn-es-archiver/src/lib/indices/__tests__/delete_index_stream.ts b/packages/kbn-es-archiver/src/lib/indices/__tests__/delete_index_stream.ts index 551b744415c830..3c9d866700005e 100644 --- a/packages/kbn-es-archiver/src/lib/indices/__tests__/delete_index_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/__tests__/delete_index_stream.ts @@ -19,7 +19,7 @@ import sinon from 'sinon'; -import { createListStream, createPromiseFromStreams } from '../../streams'; +import { createListStream, createPromiseFromStreams } from '@kbn/utils'; import { createDeleteIndexStream } from '../delete_index_stream'; diff --git a/packages/kbn-es-archiver/src/lib/indices/__tests__/generate_index_records_stream.ts b/packages/kbn-es-archiver/src/lib/indices/__tests__/generate_index_records_stream.ts index cb3746c015dada..d2c9f1274e60fb 100644 --- a/packages/kbn-es-archiver/src/lib/indices/__tests__/generate_index_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/__tests__/generate_index_records_stream.ts @@ -19,8 +19,7 @@ import sinon from 'sinon'; import expect from '@kbn/expect'; - -import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '@kbn/utils'; import { createStubClient, createStubStats } from './stubs'; diff --git a/packages/kbn-es-archiver/src/lib/records/__tests__/filter_records_stream.ts b/packages/kbn-es-archiver/src/lib/records/__tests__/filter_records_stream.ts index b23ff2e4e52acb..cf67ee2071c105 100644 --- a/packages/kbn-es-archiver/src/lib/records/__tests__/filter_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/records/__tests__/filter_records_stream.ts @@ -20,7 +20,7 @@ import Chance from 'chance'; import expect from '@kbn/expect'; -import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '@kbn/utils'; import { createFilterRecordsStream } from '../filter_records_stream'; diff --git a/packages/kbn-es-archiver/src/lib/streams/concat_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/concat_stream.test.js deleted file mode 100644 index 1498334013d1ab..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/concat_stream.test.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createListStream, createPromiseFromStreams, createConcatStream } from './'; - -describe('concatStream', () => { - test('accepts an initial value', async () => { - const output = await createPromiseFromStreams([ - createListStream([1, 2, 3]), - createConcatStream([0]), - ]); - - expect(output).toEqual([0, 1, 2, 3]); - }); - - describe(`combines using the previous value's concat method`, () => { - test('works with strings', async () => { - const output = await createPromiseFromStreams([ - createListStream(['a', 'b', 'c']), - createConcatStream(), - ]); - expect(output).toEqual('abc'); - }); - - test('works with arrays', async () => { - const output = await createPromiseFromStreams([ - createListStream([[1], [2, 3, 4], [10]]), - createConcatStream(), - ]); - expect(output).toEqual([1, 2, 3, 4, 10]); - }); - - test('works with a mixture, starting with array', async () => { - const output = await createPromiseFromStreams([ - createListStream([[], 1, 2, 3, 4, [5, 6, 7]]), - createConcatStream(), - ]); - expect(output).toEqual([1, 2, 3, 4, 5, 6, 7]); - }); - - test('fails when the value does not have a concat method', async () => { - let promise; - try { - promise = createPromiseFromStreams([createListStream([1, '1']), createConcatStream()]); - } catch (err) { - throw new Error('createPromiseFromStreams() should not fail synchronously'); - } - - try { - await promise; - throw new Error('Promise should have rejected'); - } catch (err) { - expect(err).toBeInstanceOf(Error); - expect(err.message).toContain('concat'); - } - }); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/concat_stream.ts b/packages/kbn-es-archiver/src/lib/streams/concat_stream.ts deleted file mode 100644 index 03dd894067afc5..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/concat_stream.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createReduceStream } from './reduce_stream'; - -/** - * Creates a Transform stream that consumes all provided - * values and concatenates them using each values `concat` - * method. - * - * Concatenate strings: - * createListStream(['f', 'o', 'o']) - * .pipe(createConcatStream()) - * .on('data', console.log) - * // logs "foo" - * - * Concatenate values into an array: - * createListStream([1,2,3]) - * .pipe(createConcatStream([])) - * .on('data', console.log) - * // logs "[1,2,3]" - */ -export function createConcatStream(initial: any) { - return createReduceStream((acc, chunk) => acc.concat(chunk), initial); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.test.js b/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.test.js deleted file mode 100644 index 878d645d9b4a79..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.test.js +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Readable } from 'stream'; - -import { concatStreamProviders } from './concat_stream_providers'; -import { createListStream } from './list_stream'; -import { createConcatStream } from './concat_stream'; -import { createPromiseFromStreams } from './promise_from_streams'; - -describe('concatStreamProviders() helper', () => { - test('writes the data from an array of stream providers into a destination stream in order', async () => { - const results = await createPromiseFromStreams([ - concatStreamProviders([ - () => createListStream(['foo', 'bar']), - () => createListStream(['baz']), - () => createListStream(['bug']), - ]), - createConcatStream(''), - ]); - - expect(results).toBe('foobarbazbug'); - }); - - test('emits the errors from a sub-stream to the destination', async () => { - const dest = concatStreamProviders([ - () => createListStream(['foo', 'bar']), - () => - new Readable({ - read() { - this.destroy(new Error('foo')); - }, - }), - ]); - - const errorListener = jest.fn(); - dest.on('error', errorListener); - - await expect(createPromiseFromStreams([dest])).rejects.toThrowErrorMatchingInlineSnapshot( - `"foo"` - ); - expect(errorListener.mock.calls).toMatchInlineSnapshot(` -Array [ - Array [ - [Error: foo], - ], -] -`); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.ts b/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.ts deleted file mode 100644 index be0768316b2930..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { PassThrough, TransformOptions } from 'stream'; - -/** - * Write the data and errors from a list of stream providers - * to a single stream in order. Stream providers are only - * called right before they will be consumed, and only one - * provider will be active at a time. - */ -export function concatStreamProviders( - sourceProviders: Array<() => NodeJS.ReadableStream>, - options: TransformOptions = {} -) { - const destination = new PassThrough(options); - const queue = sourceProviders.slice(); - - (function pipeNext() { - const provider = queue.shift(); - - if (!provider) { - return; - } - - const source = provider(); - const isLast = !queue.length; - - // if there are more sources to pipe, hook - // into the source completion - if (!isLast) { - source.once('end', pipeNext); - } - - source - // proxy errors from the source to the destination - .once('error', (error) => destination.destroy(error)) - // pipe the source to the destination but only proxy the - // end event if this is the last source - .pipe(destination, { end: isLast }); - })(); - - return destination; -} diff --git a/packages/kbn-es-archiver/src/lib/streams/filter_stream.test.ts b/packages/kbn-es-archiver/src/lib/streams/filter_stream.test.ts deleted file mode 100644 index 28b7f2588628e7..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/filter_stream.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - createConcatStream, - createFilterStream, - createListStream, - createPromiseFromStreams, -} from './'; - -describe('createFilterStream()', () => { - test('calls the function with each item in the source stream', async () => { - const filter = jest.fn().mockReturnValue(true); - - await createPromiseFromStreams([createListStream(['a', 'b', 'c']), createFilterStream(filter)]); - - expect(filter).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - "a", - ], - Array [ - "b", - ], - Array [ - "c", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": true, - }, - Object { - "type": "return", - "value": true, - }, - Object { - "type": "return", - "value": true, - }, - ], - } - `); - }); - - test('send the filtered values on the output stream', async () => { - const result = await createPromiseFromStreams([ - createListStream([1, 2, 3]), - createFilterStream((n) => n % 2 === 0), - createConcatStream([]), - ]); - - expect(result).toMatchInlineSnapshot(` - Array [ - 2, - ] - `); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/filter_stream.ts b/packages/kbn-es-archiver/src/lib/streams/filter_stream.ts deleted file mode 100644 index 738b9d5793d064..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/filter_stream.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -export function createFilterStream(fn: (obj: T) => boolean) { - return new Transform({ - objectMode: true, - async transform(obj, _, done) { - const canPushDownStream = fn(obj); - if (canPushDownStream) { - this.push(obj); - } - done(); - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.test.js deleted file mode 100644 index e11b36d77106a6..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.test.js +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - createPromiseFromStreams, - createListStream, - createIntersperseStream, - createConcatStream, -} from './'; - -describe('intersperseStream', () => { - test('places the intersperse value between each provided value', async () => { - expect( - await createPromiseFromStreams([ - createListStream(['to', 'be', 'or', 'not', 'to', 'be']), - createIntersperseStream(' '), - createConcatStream(), - ]) - ).toBe('to be or not to be'); - }); - - test('emits values as soon as possible, does not needlessly buffer', async () => { - const str = createIntersperseStream('y'); - const onData = jest.fn(); - str.on('data', onData); - - str.write('a'); - expect(onData).toHaveBeenCalledTimes(1); - expect(onData.mock.calls[0]).toEqual(['a']); - onData.mockClear(); - - str.write('b'); - expect(onData).toHaveBeenCalledTimes(2); - expect(onData.mock.calls[0]).toEqual(['y']); - expect(onData).toHaveBeenCalledTimes(2); - expect(onData.mock.calls[1]).toEqual(['b']); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.ts b/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.ts deleted file mode 100644 index eb2e3d3087d4ad..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -/** - * Create a Transform stream that receives values in object mode, - * and intersperses a chunk between each object received. - * - * This is useful for writing lists: - * - * createListStream(['foo', 'bar']) - * .pipe(createIntersperseStream('\n')) - * .pipe(process.stdout) // outputs "foo\nbar" - * - * Combine with a concat stream to get "join" like functionality: - * - * await createPromiseFromStreams([ - * createListStream(['foo', 'bar']), - * createIntersperseStream(' '), - * createConcatStream() - * ]) // produces a single value "foo bar" - */ -export function createIntersperseStream(intersperseChunk: any) { - let first = true; - - return new Transform({ - writableObjectMode: true, - readableObjectMode: true, - transform(chunk, _, callback) { - try { - if (first) { - first = false; - } else { - this.push(intersperseChunk); - } - - this.push(chunk); - callback(undefined); - } catch (err) { - callback(err); - } - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/list_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/list_stream.test.js deleted file mode 100644 index 12e20696b05101..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/list_stream.test.js +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createListStream } from './'; - -describe('listStream', () => { - test('provides the values in the initial list', async () => { - const str = createListStream([1, 2, 3, 4]); - const onData = jest.fn(); - str.on('data', onData); - - await new Promise((resolve) => str.on('end', resolve)); - - expect(onData).toHaveBeenCalledTimes(4); - expect(onData.mock.calls[0]).toEqual([1]); - expect(onData.mock.calls[1]).toEqual([2]); - expect(onData.mock.calls[2]).toEqual([3]); - expect(onData.mock.calls[3]).toEqual([4]); - }); - - test('does not modify the list passed', async () => { - const list = [1, 2, 3, 4]; - const str = createListStream(list); - str.resume(); - await new Promise((resolve) => str.on('end', resolve)); - expect(list).toEqual([1, 2, 3, 4]); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/list_stream.ts b/packages/kbn-es-archiver/src/lib/streams/list_stream.ts deleted file mode 100644 index c061b969b3c099..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/list_stream.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Readable } from 'stream'; - -/** - * Create a Readable stream that provides the items - * from a list as objects to subscribers - */ -export function createListStream(items: any | any[] = []) { - const queue: any[] = [].concat(items); - - return new Readable({ - objectMode: true, - read(size) { - queue.splice(0, size).forEach((item) => { - this.push(item); - }); - - if (!queue.length) { - this.push(null); - } - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/map_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/map_stream.test.js deleted file mode 100644 index d86da178f0c1b3..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/map_stream.test.js +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { delay } from 'bluebird'; - -import { createPromiseFromStreams } from './promise_from_streams'; -import { createListStream } from './list_stream'; -import { createMapStream } from './map_stream'; -import { createConcatStream } from './concat_stream'; - -describe('createMapStream()', () => { - test('calls the function with each item in the source stream', async () => { - const mapper = jest.fn(); - - await createPromiseFromStreams([createListStream(['a', 'b', 'c']), createMapStream(mapper)]); - - expect(mapper).toHaveBeenCalledTimes(3); - expect(mapper).toHaveBeenCalledWith('a', 0); - expect(mapper).toHaveBeenCalledWith('b', 1); - expect(mapper).toHaveBeenCalledWith('c', 2); - }); - - test('send the return value from the mapper on the output stream', async () => { - const result = await createPromiseFromStreams([ - createListStream([1, 2, 3]), - createMapStream((n) => n * 100), - createConcatStream([]), - ]); - - expect(result).toEqual([100, 200, 300]); - }); - - test('supports async mappers', async () => { - const result = await createPromiseFromStreams([ - createListStream([1, 2, 3]), - createMapStream(async (n, i) => { - await delay(n); - return n * i; - }), - createConcatStream([]), - ]); - - expect(result).toEqual([0, 2, 6]); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/map_stream.ts b/packages/kbn-es-archiver/src/lib/streams/map_stream.ts deleted file mode 100644 index e88c512a386535..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/map_stream.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -export function createMapStream(fn: (chunk: any, i: number) => T | Promise) { - let i = 0; - - return new Transform({ - objectMode: true, - async transform(value, _, done) { - try { - this.push(await fn(value, i++)); - done(); - } catch (err) { - done(err); - } - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.test.js b/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.test.js deleted file mode 100644 index e4d9835106f12c..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.test.js +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Readable, Writable, Duplex, Transform } from 'stream'; - -import { createListStream, createPromiseFromStreams, createReduceStream } from './'; - -describe('promiseFromStreams', () => { - test('pipes together an array of streams', async () => { - const str1 = createListStream([1, 2, 3]); - const str2 = createReduceStream((acc, n) => acc + n, 0); - const sumPromise = new Promise((resolve) => str2.once('data', resolve)); - createPromiseFromStreams([str1, str2]); - await new Promise((resolve) => str2.once('end', resolve)); - expect(await sumPromise).toBe(6); - }); - - describe('last stream is writable', () => { - test('waits for the last stream to finish writing', async () => { - let written = ''; - - await createPromiseFromStreams([ - createListStream(['a']), - new Writable({ - write(chunk, enc, cb) { - setTimeout(() => { - written += chunk; - cb(); - }, 100); - }, - }), - ]); - - expect(written).toBe('a'); - }); - - test('resolves to undefined', async () => { - const result = await createPromiseFromStreams([ - createListStream(['a']), - new Writable({ - write(chunk, enc, cb) { - cb(); - }, - }), - ]); - - expect(result).toBe(undefined); - }); - }); - - describe('last stream is readable', () => { - test(`resolves to it's final value`, async () => { - const result = await createPromiseFromStreams([createListStream(['a', 'b', 'c'])]); - - expect(result).toBe('c'); - }); - }); - - describe('last stream is duplex', () => { - test('waits for writing and resolves to final value', async () => { - let written = ''; - - const duplexReadQueue = []; - const duplexItemsToPush = ['foo', 'bar', null]; - const result = await createPromiseFromStreams([ - createListStream(['a', 'b', 'c']), - new Duplex({ - async read() { - const result = await duplexReadQueue.shift(); - this.push(result); - }, - - write(chunk, enc, cb) { - duplexReadQueue.push( - new Promise((resolve) => { - setTimeout(() => { - written += chunk; - cb(); - resolve(duplexItemsToPush.shift()); - }, 50); - }) - ); - }, - }).setEncoding('utf8'), - ]); - - expect(written).toEqual('abc'); - expect(result).toBe('bar'); - }); - }); - - describe('error handling', () => { - test('read stream gets destroyed when transform stream fails', async () => { - let destroyCalled = false; - const readStream = new Readable({ - read() { - this.push('a'); - this.push('b'); - this.push('c'); - this.push(null); - }, - destroy() { - destroyCalled = true; - }, - }); - const transformStream = new Transform({ - transform(chunk, enc, done) { - done(new Error('Test error')); - }, - }); - try { - await createPromiseFromStreams([readStream, transformStream]); - throw new Error('Should fail'); - } catch (e) { - expect(e.message).toBe('Test error'); - expect(destroyCalled).toBe(true); - } - }); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.ts b/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.ts deleted file mode 100644 index fefb18be147805..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * Take an array of streams, pipe the output - * from each one into the next, listening for - * errors from any of the streams, and then resolve - * the promise once the final stream has finished - * writing/reading. - * - * If the last stream is readable, it's final value - * will be provided as the promise value. - * - * Errors emitted from any stream will cause - * the promise to be rejected with that error. - */ - -import { pipeline, Writable } from 'stream'; -import { promisify } from 'util'; - -const asyncPipeline = promisify(pipeline); - -export async function createPromiseFromStreams(streams: any): Promise { - let finalChunk: any; - const last = streams[streams.length - 1]; - if (typeof last.read !== 'function' && streams.length === 1) { - // For a nicer error than what stream.pipeline throws - throw new Error('A minimum of 2 streams is required when a non-readable stream is given'); - } - if (typeof last.read === 'function') { - // We are pushing a writable stream to capture the last chunk - streams.push( - new Writable({ - // Use object mode even when "last" stream isn't. This allows to - // capture the last chunk as-is. - objectMode: true, - write(chunk, _, done) { - finalChunk = chunk; - done(); - }, - }) - ); - } - - await asyncPipeline(...(streams as [any])); - - return finalChunk; -} diff --git a/packages/kbn-es-archiver/src/lib/streams/reduce_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/reduce_stream.test.js deleted file mode 100644 index 2c073f67f82a89..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/reduce_stream.test.js +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createReduceStream, createPromiseFromStreams, createListStream } from './'; - -const promiseFromEvent = (name, emitter) => - new Promise((resolve) => emitter.on(name, () => resolve(name))); - -describe('reduceStream', () => { - test('calls the reducer for each item provided', async () => { - const stub = jest.fn(); - await createPromiseFromStreams([ - createListStream([1, 2, 3]), - createReduceStream((val, chunk, enc) => { - stub(val, chunk, enc); - return chunk; - }, 0), - ]); - expect(stub).toHaveBeenCalledTimes(3); - expect(stub.mock.calls[0]).toEqual([0, 1, 'utf8']); - expect(stub.mock.calls[1]).toEqual([1, 2, 'utf8']); - expect(stub.mock.calls[2]).toEqual([2, 3, 'utf8']); - }); - - test('provides the return value of the last iteration of the reducer', async () => { - const result = await createPromiseFromStreams([ - createListStream('abcdefg'.split('')), - createReduceStream((acc) => acc + 1, 0), - ]); - expect(result).toBe(7); - }); - - test('emits an error if an iteration fails', async () => { - const reduce = createReduceStream((acc, i) => expect(i).toBe(1), 0); - const errorEvent = promiseFromEvent('error', reduce); - - reduce.write(1); - reduce.write(2); - reduce.resume(); - await errorEvent; - }); - - test('stops calling the reducer if an iteration fails, emits no data', async () => { - const reducer = jest.fn((acc, i) => { - if (i < 100) return acc + i; - else throw new Error(i); - }); - const reduce$ = createReduceStream(reducer, 0); - - const dataStub = jest.fn(); - const errorStub = jest.fn(); - reduce$.on('data', dataStub); - reduce$.on('error', errorStub); - const endEvent = promiseFromEvent('end', reduce$); - - reduce$.write(1); - reduce$.write(2); - reduce$.write(300); - reduce$.write(400); - reduce$.write(1000); - reduce$.end(); - - await endEvent; - expect(reducer).toHaveBeenCalledTimes(3); - expect(dataStub).toHaveBeenCalledTimes(0); - expect(errorStub).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/reduce_stream.ts b/packages/kbn-es-archiver/src/lib/streams/reduce_stream.ts deleted file mode 100644 index d9458e9a11c33a..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/reduce_stream.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -/** - * Create a transform stream that consumes each chunk it receives - * and passes it to the reducer, which will return the new value - * for the stream. Once all chunks have been received the reduce - * stream provides the result of final call to the reducer to - * subscribers. - */ -export function createReduceStream( - reducer: (acc: any, chunk: any, env: string) => any, - initial: any -) { - let i = -1; - let value = initial; - - // if the reducer throws an error then the value is - // considered invalid and the stream will never provide - // it to subscribers. We will also stop calling the - // reducer for any new data that is provided to us - let failed = false; - - if (typeof reducer !== 'function') { - throw new TypeError('reducer must be a function'); - } - - return new Transform({ - readableObjectMode: true, - writableObjectMode: true, - async transform(chunk, enc, callback) { - try { - if (failed) { - return callback(); - } - - i += 1; - if (i === 0 && initial === undefined) { - value = chunk; - } else { - value = await reducer(value, chunk, enc); - } - - callback(); - } catch (err) { - failed = true; - callback(err); - } - }, - - flush(callback) { - if (!failed) { - this.push(value); - } - - callback(); - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/replace_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/replace_stream.test.js deleted file mode 100644 index 01b89f93e5af06..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/replace_stream.test.js +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - createReplaceStream, - createConcatStream, - createPromiseFromStreams, - createListStream, - createMapStream, -} from './'; - -async function concatToString(streams) { - return await createPromiseFromStreams([ - ...streams, - createMapStream((buff) => buff.toString('utf8')), - createConcatStream(''), - ]); -} - -describe('replaceStream', () => { - test('produces buffers when it receives buffers', async () => { - const chunks = await createPromiseFromStreams([ - createListStream([Buffer.from('foo'), Buffer.from('bar')]), - createReplaceStream('o', '0'), - createConcatStream([]), - ]); - - chunks.forEach((chunk) => { - expect(chunk).toBeInstanceOf(Buffer); - }); - }); - - test('produces buffers when it receives strings', async () => { - const chunks = await createPromiseFromStreams([ - createListStream(['foo', 'bar']), - createReplaceStream('o', '0'), - createConcatStream([]), - ]); - - chunks.forEach((chunk) => { - expect(chunk).toBeInstanceOf(Buffer); - }); - }); - - test('expects toReplace to be a string', () => { - expect(() => createReplaceStream(Buffer.from('foo'))).toThrowError(/be a string/); - }); - - test('replaces multiple single-char instances in a single chunk', async () => { - expect( - await concatToString([ - createListStream([Buffer.from('f00 bar')]), - createReplaceStream('0', 'o'), - ]) - ).toBe('foo bar'); - }); - - test('replaces multiple single-char instances in multiple chunks', async () => { - expect( - await concatToString([ - createListStream([Buffer.from('f0'), Buffer.from('0 bar')]), - createReplaceStream('0', 'o'), - ]) - ).toBe('foo bar'); - }); - - test('replaces single multi-char instances in single chunks', async () => { - expect( - await concatToString([ - createListStream([Buffer.from('f0'), Buffer.from('0 bar')]), - createReplaceStream('0', 'o'), - ]) - ).toBe('foo bar'); - }); - - test('replaces multiple multi-char instances in single chunks', async () => { - expect( - await concatToString([ - createListStream([Buffer.from('foo ba'), Buffer.from('r b'), Buffer.from('az bar')]), - createReplaceStream('bar', '*'), - ]) - ).toBe('foo * baz *'); - }); - - test('replaces multi-char instance that stretches multiple chunks', async () => { - expect( - await concatToString([ - createListStream([ - Buffer.from('foo supe'), - Buffer.from('rcalifra'), - Buffer.from('gilistic'), - Buffer.from('expialid'), - Buffer.from('ocious bar'), - ]), - createReplaceStream('supercalifragilisticexpialidocious', '*'), - ]) - ).toBe('foo * bar'); - }); - - test('ignores missing multi-char instance', async () => { - expect( - await concatToString([ - createListStream([ - Buffer.from('foo supe'), - Buffer.from('rcalifra'), - Buffer.from('gili stic'), - Buffer.from('expialid'), - Buffer.from('ocious bar'), - ]), - createReplaceStream('supercalifragilisticexpialidocious', '*'), - ]) - ).toBe('foo supercalifragili sticexpialidocious bar'); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/replace_stream.ts b/packages/kbn-es-archiver/src/lib/streams/replace_stream.ts deleted file mode 100644 index fe2ba1fcdf31c9..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/replace_stream.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -export function createReplaceStream(toReplace: string, replacement: string) { - if (typeof toReplace !== 'string') { - throw new TypeError('toReplace must be a string'); - } - - let buffer = Buffer.alloc(0); - return new Transform({ - objectMode: false, - async transform(value, _, done) { - try { - buffer = Buffer.concat([buffer, value], buffer.length + value.length); - - while (true) { - // try to find the next instance of `toReplace` in buffer - const index = buffer.indexOf(toReplace); - - // if there is no next instance, break - if (index === -1) { - break; - } - - // flush everything to the left of the next instance - // of `toReplace` - this.push(buffer.slice(0, index)); - - // then flush an instance of `replacement` - this.push(replacement); - - // and finally update the buffer to include everything - // to the right of `toReplace`, dropping to replace from the buffer - buffer = buffer.slice(index + toReplace.length); - } - - // until now we have only flushed data that is to the left - // of a discovered instance of `toReplace`. If `toReplace` is - // never found this would lead to us buffering the entire stream. - // - // Instead, we only keep enough buffer to complete a potentially - // partial instance of `toReplace` - if (buffer.length > toReplace.length) { - // the entire buffer except the last `toReplace.length` bytes - // so that if all but one byte from `toReplace` is in the buffer, - // and the next chunk delivers the necessary byte, the buffer will then - // contain a complete `toReplace` token. - this.push(buffer.slice(0, buffer.length - toReplace.length)); - buffer = buffer.slice(-toReplace.length); - } - - done(); - } catch (err) { - done(err); - } - }, - - flush(callback) { - if (buffer.length) { - this.push(buffer); - } - - callback(); - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/split_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/split_stream.test.js deleted file mode 100644 index e0736d220ba5ca..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/split_stream.test.js +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createSplitStream, createConcatStream, createPromiseFromStreams } from './'; - -async function split(stream, input) { - const concat = createConcatStream(); - concat.write([]); - stream.pipe(concat); - const output = createPromiseFromStreams([concat]); - - input.forEach((i) => { - stream.write(i); - }); - stream.end(); - - return await output; -} - -describe('splitStream', () => { - test('splits buffers, produces strings', async () => { - const output = await split(createSplitStream('&'), [Buffer.from('foo&bar')]); - expect(output).toEqual(['foo', 'bar']); - }); - - test('supports mixed input', async () => { - const output = await split(createSplitStream('&'), [Buffer.from('foo&b'), 'ar']); - expect(output).toEqual(['foo', 'bar']); - }); - - test('supports buffer split chunks', async () => { - const output = await split(createSplitStream(Buffer.from('&')), ['foo&b', 'ar']); - expect(output).toEqual(['foo', 'bar']); - }); - - test('splits provided values by a delimiter', async () => { - const output = await split(createSplitStream('&'), ['foo&b', 'ar']); - expect(output).toEqual(['foo', 'bar']); - }); - - test('handles multi-character delimiters', async () => { - const output = await split(createSplitStream('oo'), ['foo&b', 'ar']); - expect(output).toEqual(['f', '&bar']); - }); - - test('handles delimiters that span multiple chunks', async () => { - const output = await split(createSplitStream('ba'), ['foo&b', 'ar']); - expect(output).toEqual(['foo&', 'r']); - }); - - test('produces an empty chunk if the split char is at the end of the input', async () => { - const output = await split(createSplitStream('&bar'), ['foo&b', 'ar']); - expect(output).toEqual(['foo', '']); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/split_stream.ts b/packages/kbn-es-archiver/src/lib/streams/split_stream.ts deleted file mode 100644 index 1c9b59449bd927..00000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/split_stream.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -/** - * Creates a Transform stream that consumes a stream of Buffers - * and produces a stream of strings (in object mode) by splitting - * the received bytes using the splitChunk. - * - * Ways this is behaves like String#split: - * - instances of splitChunk are removed from the input - * - splitChunk can be on any size - * - if there are no bytes found after the last splitChunk - * a final empty chunk is emitted - * - * Ways this deviates from String#split: - * - splitChunk cannot be a regexp - * - an empty string or Buffer will not produce a stream of individual - * bytes like `string.split('')` would - */ -export function createSplitStream(splitChunk: string) { - let unsplitBuffer = Buffer.alloc(0); - - return new Transform({ - writableObjectMode: false, - readableObjectMode: true, - transform(chunk, _, callback) { - try { - let i; - let toSplit = Buffer.concat([unsplitBuffer, chunk]); - while ((i = toSplit.indexOf(splitChunk)) !== -1) { - const slice = toSplit.slice(0, i); - toSplit = toSplit.slice(i + splitChunk.length); - this.push(slice.toString('utf8')); - } - - unsplitBuffer = toSplit; - callback(undefined); - } catch (err) { - callback(err); - } - }, - - flush(callback) { - try { - this.push(unsplitBuffer.toString('utf8')); - - callback(undefined); - } catch (err) { - callback(err); - } - }, - }); -} diff --git a/packages/kbn-legacy-logging/package.json b/packages/kbn-legacy-logging/package.json index 9311b3e2a77b37..808fedc788d944 100644 --- a/packages/kbn-legacy-logging/package.json +++ b/packages/kbn-legacy-logging/package.json @@ -10,6 +10,6 @@ "kbn:watch": "yarn build --watch" }, "dependencies": { - "@kbn/std": "link:../kbn-std" + "@kbn/utils": "link:../kbn-utils" } } diff --git a/packages/kbn-legacy-logging/src/log_format_json.test.ts b/packages/kbn-legacy-logging/src/log_format_json.test.ts index f762daf01e5fa9..b31c45535e1a9f 100644 --- a/packages/kbn-legacy-logging/src/log_format_json.test.ts +++ b/packages/kbn-legacy-logging/src/log_format_json.test.ts @@ -20,7 +20,7 @@ import moment from 'moment'; import { attachMetaData } from './metadata'; -import { createListStream, createPromiseFromStreams } from './test_utils'; +import { createListStream, createPromiseFromStreams } from '@kbn/utils'; import { KbnLoggerJsonFormat } from './log_format_json'; const time = +moment('2010-01-01T05:15:59Z', moment.ISO_8601); diff --git a/packages/kbn-legacy-logging/src/log_format_string.test.ts b/packages/kbn-legacy-logging/src/log_format_string.test.ts index 0ed233228c1fd9..d11a4a038d49a9 100644 --- a/packages/kbn-legacy-logging/src/log_format_string.test.ts +++ b/packages/kbn-legacy-logging/src/log_format_string.test.ts @@ -20,7 +20,7 @@ import moment from 'moment'; import { attachMetaData } from './metadata'; -import { createListStream, createPromiseFromStreams } from './test_utils'; +import { createListStream, createPromiseFromStreams } from '@kbn/utils'; import { KbnLoggerStringFormat } from './log_format_string'; const time = +moment('2010-01-01T05:15:59Z', moment.ISO_8601); diff --git a/packages/kbn-legacy-logging/src/test_utils/streams.ts b/packages/kbn-legacy-logging/src/test_utils/streams.ts deleted file mode 100644 index 0f37a13f8a478b..00000000000000 --- a/packages/kbn-legacy-logging/src/test_utils/streams.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { pipeline, Writable, Readable } from 'stream'; - -/** - * Create a Readable stream that provides the items - * from a list as objects to subscribers - * - * @param {Array} items - the list of items to provide - * @return {Readable} - */ -export function createListStream(items: T | T[] = []) { - const queue = Array.isArray(items) ? [...items] : [items]; - - return new Readable({ - objectMode: true, - read(size) { - queue.splice(0, size).forEach((item) => { - this.push(item); - }); - - if (!queue.length) { - this.push(null); - } - }, - }); -} - -/** - * Take an array of streams, pipe the output - * from each one into the next, listening for - * errors from any of the streams, and then resolve - * the promise once the final stream has finished - * writing/reading. - * - * If the last stream is readable, it's final value - * will be provided as the promise value. - * - * Errors emitted from any stream will cause - * the promise to be rejected with that error. - * - * @param {Array} streams - * @return {Promise} - */ - -function isReadable(stream: Readable | Writable): stream is Readable { - return 'read' in stream && typeof stream.read === 'function'; -} - -export async function createPromiseFromStreams(streams: [Readable, ...Writable[]]): Promise { - let finalChunk: any; - const last = streams[streams.length - 1]; - if (!isReadable(last) && streams.length === 1) { - // For a nicer error than what stream.pipeline throws - throw new Error('A minimum of 2 streams is required when a non-readable stream is given'); - } - if (isReadable(last)) { - // We are pushing a writable stream to capture the last chunk - streams.push( - new Writable({ - // Use object mode even when "last" stream isn't. This allows to - // capture the last chunk as-is. - objectMode: true, - write(chunk, enc, done) { - finalChunk = chunk; - done(); - }, - }) - ); - } - - return new Promise((resolve, reject) => { - // @ts-expect-error 'pipeline' doesn't support variable length of arguments - pipeline(...streams, (err) => { - if (err) return reject(err); - resolve(finalChunk); - }); - }); -} diff --git a/packages/kbn-utils/src/index.ts b/packages/kbn-utils/src/index.ts index 7a894d72d5624b..30362112140aa2 100644 --- a/packages/kbn-utils/src/index.ts +++ b/packages/kbn-utils/src/index.ts @@ -20,3 +20,4 @@ export * from './package_json'; export * from './path'; export * from './repo_root'; +export * from './streams'; diff --git a/src/core/server/utils/streams/concat_stream.test.ts b/packages/kbn-utils/src/streams/concat_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/concat_stream.test.ts rename to packages/kbn-utils/src/streams/concat_stream.test.ts diff --git a/src/core/server/utils/streams/concat_stream.ts b/packages/kbn-utils/src/streams/concat_stream.ts similarity index 100% rename from src/core/server/utils/streams/concat_stream.ts rename to packages/kbn-utils/src/streams/concat_stream.ts diff --git a/src/core/server/utils/streams/concat_stream_providers.test.ts b/packages/kbn-utils/src/streams/concat_stream_providers.test.ts similarity index 100% rename from src/core/server/utils/streams/concat_stream_providers.test.ts rename to packages/kbn-utils/src/streams/concat_stream_providers.test.ts diff --git a/src/core/server/utils/streams/concat_stream_providers.ts b/packages/kbn-utils/src/streams/concat_stream_providers.ts similarity index 100% rename from src/core/server/utils/streams/concat_stream_providers.ts rename to packages/kbn-utils/src/streams/concat_stream_providers.ts diff --git a/src/core/server/utils/streams/filter_stream.test.ts b/packages/kbn-utils/src/streams/filter_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/filter_stream.test.ts rename to packages/kbn-utils/src/streams/filter_stream.test.ts diff --git a/src/core/server/utils/streams/filter_stream.ts b/packages/kbn-utils/src/streams/filter_stream.ts similarity index 100% rename from src/core/server/utils/streams/filter_stream.ts rename to packages/kbn-utils/src/streams/filter_stream.ts diff --git a/packages/kbn-es-archiver/src/lib/streams/index.ts b/packages/kbn-utils/src/streams/index.ts similarity index 100% rename from packages/kbn-es-archiver/src/lib/streams/index.ts rename to packages/kbn-utils/src/streams/index.ts diff --git a/src/core/server/utils/streams/intersperse_stream.test.ts b/packages/kbn-utils/src/streams/intersperse_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/intersperse_stream.test.ts rename to packages/kbn-utils/src/streams/intersperse_stream.test.ts diff --git a/src/core/server/utils/streams/intersperse_stream.ts b/packages/kbn-utils/src/streams/intersperse_stream.ts similarity index 100% rename from src/core/server/utils/streams/intersperse_stream.ts rename to packages/kbn-utils/src/streams/intersperse_stream.ts diff --git a/src/core/server/utils/streams/list_stream.test.ts b/packages/kbn-utils/src/streams/list_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/list_stream.test.ts rename to packages/kbn-utils/src/streams/list_stream.test.ts diff --git a/src/core/server/utils/streams/list_stream.ts b/packages/kbn-utils/src/streams/list_stream.ts similarity index 100% rename from src/core/server/utils/streams/list_stream.ts rename to packages/kbn-utils/src/streams/list_stream.ts diff --git a/src/core/server/utils/streams/map_stream.test.ts b/packages/kbn-utils/src/streams/map_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/map_stream.test.ts rename to packages/kbn-utils/src/streams/map_stream.test.ts diff --git a/src/core/server/utils/streams/map_stream.ts b/packages/kbn-utils/src/streams/map_stream.ts similarity index 100% rename from src/core/server/utils/streams/map_stream.ts rename to packages/kbn-utils/src/streams/map_stream.ts diff --git a/src/core/server/utils/streams/promise_from_streams.test.ts b/packages/kbn-utils/src/streams/promise_from_streams.test.ts similarity index 100% rename from src/core/server/utils/streams/promise_from_streams.test.ts rename to packages/kbn-utils/src/streams/promise_from_streams.test.ts diff --git a/src/core/server/utils/streams/promise_from_streams.ts b/packages/kbn-utils/src/streams/promise_from_streams.ts similarity index 100% rename from src/core/server/utils/streams/promise_from_streams.ts rename to packages/kbn-utils/src/streams/promise_from_streams.ts diff --git a/src/core/server/utils/streams/reduce_stream.test.ts b/packages/kbn-utils/src/streams/reduce_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/reduce_stream.test.ts rename to packages/kbn-utils/src/streams/reduce_stream.test.ts diff --git a/src/core/server/utils/streams/reduce_stream.ts b/packages/kbn-utils/src/streams/reduce_stream.ts similarity index 100% rename from src/core/server/utils/streams/reduce_stream.ts rename to packages/kbn-utils/src/streams/reduce_stream.ts diff --git a/src/core/server/utils/streams/replace_stream.test.ts b/packages/kbn-utils/src/streams/replace_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/replace_stream.test.ts rename to packages/kbn-utils/src/streams/replace_stream.test.ts diff --git a/src/core/server/utils/streams/replace_stream.ts b/packages/kbn-utils/src/streams/replace_stream.ts similarity index 100% rename from src/core/server/utils/streams/replace_stream.ts rename to packages/kbn-utils/src/streams/replace_stream.ts diff --git a/src/core/server/utils/streams/split_stream.test.ts b/packages/kbn-utils/src/streams/split_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/split_stream.test.ts rename to packages/kbn-utils/src/streams/split_stream.test.ts diff --git a/src/core/server/utils/streams/split_stream.ts b/packages/kbn-utils/src/streams/split_stream.ts similarity index 100% rename from src/core/server/utils/streams/split_stream.ts rename to packages/kbn-utils/src/streams/split_stream.ts diff --git a/src/cli_keystore/add.js b/src/cli_keystore/add.js index d88256da1aa592..cec25b631f07ba 100644 --- a/src/cli_keystore/add.js +++ b/src/cli_keystore/add.js @@ -19,7 +19,8 @@ import { Logger } from '../cli_plugin/lib/logger'; import { confirm, question } from './utils'; -import { createPromiseFromStreams, createConcatStream } from '../core/server/utils'; +// import from path since add.test.js mocks 'fs' required for @kbn/utils +import { createPromiseFromStreams, createConcatStream } from '@kbn/utils/target/streams'; /** * @param {Keystore} keystore diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md deleted file mode 100644 index 49b962670220ce..00000000000000 --- a/src/core/MIGRATION.md +++ /dev/null @@ -1,1774 +0,0 @@ -# Migrating legacy plugins to the new platform - -- [Migrating legacy plugins to the new platform](#migrating-legacy-plugins-to-the-new-platform) - - [Overview](#overview) - - [Architecture](#architecture) - - [Services](#services) - - [Integrating with other plugins](#integrating-with-other-plugins) - - [Challenges to overcome with legacy plugins](#challenges-to-overcome-with-legacy-plugins) - - [Challenges on the server](#challenges-on-the-server) - - [Challenges in the browser](#challenges-in-the-browser) - - [Plan of action](#plan-of-action) - - [Server-side plan of action](#server-side-plan-of-action) - - [De-couple from hapi.js server and request objects](#de-couple-from-hapijs-server-and-request-objects) - - [Introduce new plugin definition shim](#introduce-new-plugin-definition-shim) - - [Switch to new platform services](#switch-to-new-platform-services) - - [Migrate to the new plugin system](#migrate-to-the-new-plugin-system) - - [Browser-side plan of action](#browser-side-plan-of-action) - - [1. Create a plugin definition file](#1-create-a-plugin-definition-file) - - [2. Export all static code and types from `public/index.ts`](#2-export-all-static-code-and-types-from-publicindexts) - - [3. Export your runtime contract](#3-export-your-runtime-contract) - - [4. Move "owned" UI modules into your plugin and expose them from your public contract](#4-move-owned-ui-modules-into-your-plugin-and-expose-them-from-your-public-contract) - - [5. Provide plugin extension points decoupled from angular.js](#5-provide-plugin-extension-points-decoupled-from-angularjs) - - [6. Move all webpack alias imports into uiExport entry files](#6-move-all-webpack-alias-imports-into-uiexport-entry-files) - - [7. Switch to new platform services](#7-switch-to-new-platform-services) - - [8. Migrate to the new plugin system](#8-migrate-to-the-new-plugin-system) - - [Bonus: Tips for complex migration scenarios](#bonus-tips-for-complex-migration-scenarios) - - [Keep Kibana fast](#keep-kibana-fast) - - [Frequently asked questions](#frequently-asked-questions) - - [Is migrating a plugin an all-or-nothing thing?](#is-migrating-a-plugin-an-all-or-nothing-thing) - - [Do plugins need to be converted to TypeScript?](#do-plugins-need-to-be-converted-to-typescript) - - [Can static code be shared between plugins?](#can-static-code-be-shared-between-plugins) - - [Background](#background) - - [What goes wrong if I do share modules with state?](#what-goes-wrong-if-i-do-share-modules-with-state) - - [How to decide what code can be statically imported](#how-to-decide-what-code-can-be-statically-imported) - - [Concrete Example](#concrete-example) - - [How can I avoid passing Core services deeply within my UI component tree?](#how-can-i-avoid-passing-core-services-deeply-within-my-ui-component-tree) - - [How is "common" code shared on both the client and server?](#how-is-common-code-shared-on-both-the-client-and-server) - - [When does code go into a plugin, core, or packages?](#when-does-code-go-into-a-plugin-core-or-packages) - - [How do I build my shim for New Platform services?](#how-do-i-build-my-shim-for-new-platform-services) - - [Client-side](#client-side) - - [Core services](#core-services) - - [Plugins for shared application services](#plugins-for-shared-application-services) - - [Server-side](#server-side) - - [Core services](#core-services-1) - - [Plugin services](#plugin-services) - - [UI Exports](#ui-exports) - - [Plugin Spec](#plugin-spec) - - [How to](#how-to) - - [Configure plugin](#configure-plugin) - - [Handle plugin configuration deprecations](#handle-plugin-configuration-deprecations) - - [Use scoped services](#use-scoped-services) - - [Declare a custom scoped service](#declare-a-custom-scoped-service) - - [Mock new platform services in tests](#mock-new-platform-services-in-tests) - - [Writing mocks for your plugin](#writing-mocks-for-your-plugin) - - [Using mocks in your tests](#using-mocks-in-your-tests) - - [What about karma tests?](#what-about-karma-tests) - - [Provide Legacy Platform API to the New platform plugin](#provide-legacy-platform-api-to-the-new-platform-plugin) - - [On the server side](#on-the-server-side) - - [On the client side](#on-the-client-side) - - [Updates an application navlink at runtime](#updates-an-application-navlink-at-runtime) - - [Logging config migration](#logging-config-migration) - - [Use HashRouter in migrated apps](#use-react-hashrouter-in-migrated-apps) - -Make no mistake, it is going to take a lot of work to move certain plugins to the new platform. Our target is to migrate the entire repo over to the new platform throughout 7.x and to remove the legacy plugin system no later than 8.0, and this is only possible if teams start on the effort now. - -The goal of this document is to guide teams through the recommended process of migrating at a high level. Every plugin is different, so teams should tweak this plan based on their unique requirements. - -We'll start with an overview of how plugins work in the new platform, and we'll end with a generic plan of action that can be applied to any plugin in the repo today. - -## Overview - -Plugins in the new platform are not especially novel or complicated to describe. Our intention wasn't to build some clever system that magically solved problems through abstractions and layers of obscurity, and we wanted to make sure plugins could continue to use most of the same technologies they use today, at least from a technical perspective. - -New platform plugins exist in the `src/plugins` and `x-pack/plugins` directories. _See all [conventions for first-party Elastic plugins](./CONVENTIONS.md)_. - -### Architecture - -Plugins are defined as classes and exposed to the platform itself through a simple wrapper function. A plugin can have browser side code, server side code, or both. There is no architectural difference between a plugin in the browser and a plugin on the server, which is to say that in both places you describe your plugin similarly, and you interact with core and/or other plugins in the same way. - -The basic file structure of a new platform plugin named "demo" that had both client-side and server-side code would be: - -```tree -src/plugins - demo - kibana.json [1] - public - index.ts [2] - plugin.ts [3] - server - index.ts [4] - plugin.ts [5] -``` - -**[1] `kibana.json`** is a [static manifest](../../docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) file that is used to identify the plugin and to determine what kind of code the platform should execute from the plugin: - -```json -{ - "id": "demo", - "version": "kibana", - "server": true, - "ui": true -} -``` - -More details about[manifest file format](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) - -Note that `package.json` files are irrelevant to and ignored by the new platform. - -**[2] `public/index.ts`** is the entry point into the client-side code of this plugin. It must export a function named `plugin`, which will receive a standard set of core capabilities as an argument (e.g. logger). It should return an instance of its plugin definition for the platform to register at load time. - -```ts -import { PluginInitializerContext } from 'kibana/server'; -import { Plugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new Plugin(initializerContext); -} -``` - -**[3] `public/plugin.ts`** is the client-side plugin definition itself. Technically speaking it does not need to be a class or even a separate file from the entry point, but _all plugins at Elastic_ should be consistent in this way. _See all [conventions for first-party Elastic plugins](./CONVENTIONS.md)_. - -```ts -import { PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; - -export class Plugin { - constructor(initializerContext: PluginInitializerContext) {} - - public setup(core: CoreSetup) { - // called when plugin is setting up - } - - public start(core: CoreStart) { - // called after all plugins are set up - } - - public stop() { - // called when plugin is torn down, aka window.onbeforeunload - } -} -``` - -**[4] `server/index.ts`** is the entry-point into the server-side code of this plugin. It is identical in almost every way to the client-side entry-point: - -```ts -import { PluginInitializerContext } from 'kibana/server'; -import { Plugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new Plugin(initializerContext); -} -``` - -**[5] `server/plugin.ts`** is the server-side plugin definition. The _shape_ of this plugin is the same as it's client-side counter-part: - -```ts -import { PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; - -export class Plugin { - constructor(initializerContext: PluginInitializerContext) {} - - public setup(core: CoreSetup) { - // called when plugin is setting up during Kibana's startup sequence - } - - public start(core: CoreStart) { - // called after all plugins are set up - } - - public stop() { - // called when plugin is torn down during Kibana's shutdown sequence - } -} -``` - -The platform does not impose any technical restrictions on how the internals of the plugin are architected, though there are certain considerations related to how plugins interact with core and how plugins interact with other plugins that may greatly impact how they are built. - -### Services - -The various independent domains that make up `core` are represented by a series of services, and many of those services expose public interfaces that are provided to _all_ plugins. Services expose different features at different parts of their _lifecycle_. We describe the lifecycle of core services and plugins with specifically-named functions on the service definition. - -In the new platform, there are three lifecycle functions today: `setup`, `start`, and `stop`. The `setup` functions are invoked sequentially while Kibana is setting up on the server or when it is being loaded in the browser. The `start` functions are invoked sequentially after setup has completed for all plugins. The `stop` functions are invoked sequentially while Kibana is gracefully shutting down on the server or when the browser tab or window is being closed. - -The table below explains how each lifecycle event relates to the state of Kibana. - -| lifecycle event | server | browser | -| --------------- | ----------------------------------------- | --------------------------------------------------- | -| *setup* | bootstrapping and configuring routes | loading plugin bundles and configuring applications | -| *start* | server is now serving traffic | browser is now showing UI to the user | -| *stop* | server has received a request to shutdown | user is navigating away from Kibana | - -There is no equivalent behavior to `start` or `stop` in legacy plugins, so this guide primarily focuses on migrating functionality into `setup`. - -The lifecycle-specific contracts exposed by core services are always passed as the first argument to the equivalent lifecycle function in a plugin. For example, the core `UiSettings` service exposes a function `get` to all plugin `setup` functions. To use this function to retrieve a specific UI setting, a plugin just accesses it off of the first argument: - -```ts -import { CoreSetup } from 'kibana/server'; - -export class Plugin { - public setup(core: CoreSetup) { - core.uiSettings.get('courier:maxShardsBeforeCryTime'); - } -} -``` - -Different service interfaces can and will be passed to `setup` and `stop` because certain functionality makes sense in the context of a running plugin while other types of functionality may have restrictions or may only make sense in the context of a plugin that is stopping. - -For example, the `stop` function in the browser gets invoked as part of the `window.onbeforeunload` event, which means you can't necessarily execute asynchronous code here in a reliable way. For that reason, `core` likely wouldn't provide any asynchronous functions to plugin `stop` functions in the browser. - -Core services that expose functionality to plugins always have their `setup` function ran before any plugins. - -These are the contracts exposed by the core services for each lifecycle event: - -| lifecycle event | contract | -| --------------- | --------------------------------------------------------------------------------------------------------------- | -| *contructor* | [PluginInitializerContext](../../docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md) | -| *setup* | [CoreSetup](../../docs/development/core/server/kibana-plugin-core-server.coresetup.md) | -| *start* | [CoreStart](../../docs/development/core/server/kibana-plugin-core-server.corestart.md) | -| *stop* | | - -### Integrating with other plugins - -Plugins can expose public interfaces for other plugins to consume. Like `core`, those interfaces are bound to the lifecycle functions `setup` and/or `start`. - -Anything returned from `setup` or `start` will act as the interface, and while not a technical requirement, all first-party Elastic plugins should expose types for that interface as well. 3rd party plugins wishing to allow other plugins to integrate with it are also highly encouraged to expose types for their plugin interfaces. - -**foobar plugin.ts:** - -```ts -export type FoobarPluginSetup = ReturnType; -export type FoobarPluginStart = ReturnType; - -export class Plugin { - public setup() { - return { - getFoo() { - return 'foo'; - }, - }; - } - - public start() { - return { - getBar() { - return 'bar'; - }, - }; - } -} -``` - -Unlike core, capabilities exposed by plugins are _not_ automatically injected into all plugins. Instead, if a plugin wishes to use the public interface provided by another plugin, they must first declare that plugin as a dependency in their `kibana.json`. - -**demo kibana.json:** - -```json -{ - "id": "demo", - "requiredPlugins": ["foobar"], - "server": true, - "ui": true -} -``` - -With that specified in the plugin manifest, the appropriate interfaces are then available via the second argument of `setup` and/or `start`: - -**demo plugin.ts:** - -```ts -import { CoreSetup, CoreStart } from 'src/core/server'; -import { FoobarPluginSetup, FoobarPluginStop } from '../../foobar/server'; - -interface DemoSetupPlugins { - foobar: FoobarPluginSetup; -} - -interface DemoStartPlugins { - foobar: FoobarPluginStart; -} - -export class Plugin { - public setup(core: CoreSetup, plugins: DemoSetupPlugins) { - const { foobar } = plugins; - foobar.getFoo(); // 'foo' - foobar.getBar(); // throws because getBar does not exist - } - - public start(core: CoreStart, plugins: DemoStartPlugins) { - const { foobar } = plugins; - foobar.getFoo(); // throws because getFoo does not exist - foobar.getBar(); // 'bar' - } - - public stop() {}, -} -``` - -### Challenges to overcome with legacy plugins - -New platform plugins have identical architecture in the browser and on the server. Legacy plugins have one architecture that they use in the browser and an entirely different architecture that they use on the server. - -This means that there are unique sets of challenges for migrating to the new platform depending on whether the legacy plugin code is on the server or in the browser. - -#### Challenges on the server - -The general shape/architecture of legacy server-side code is similar to the new platform architecture in one important way: most legacy server-side plugins define an `init` function where the bulk of their business logic begins, and they access both "core" and "plugin-provided" functionality through the arguments given to `init`. Rarely does legacy server-side code share stateful services via import statements. - -While not exactly the same, legacy plugin `init` functions behave similarly today as new platform `setup` functions. `KbnServer` also exposes an `afterPluginsInit` method which behaves similarly to `start`. There is no corresponding legacy concept of `stop`, however. - -Despite their similarities, server-side plugins pose a formidable challenge: legacy core and plugin functionality is retrieved from either the hapi.js `server` or `request` god objects. Worse, these objects are often passed deeply throughout entire plugins, which directly couples business logic with hapi. And the worst of it all is, these objects are mutable at any time. - -The key challenge to overcome with legacy server-side plugins will decoupling from hapi. - -#### Challenges in the browser - -The legacy plugin system in the browser is fundamentally incompatible with the new platform. There is no client-side plugin definition. There are no services that get passed to plugins at runtime. There really isn't even a concrete notion of "core". - -When a legacy browser plugin needs to access functionality from another plugin, say to register a UI section to render within another plugin, it imports a stateful (global singleton) JavaScript module and performs some sort of state mutation. Sometimes this module exists inside the plugin itself, and it gets imported via the `plugin/` webpack alias. Sometimes this module exists outside the context of plugins entirely and gets imported via the `ui/` webpack alias. Neither of these concepts exist in the new platform. - -Legacy browser plugins rely on the feature known as `uiExports/`, which integrates directly with our build system to ensure that plugin code is bundled together in such a way to enable that global singleton module state. There is no corresponding feature in the new platform, and in fact we intend down the line to build new platform plugins as immutable bundles that can not share state in this way. - -The key challenge to overcome with legacy browser-side plugins will be converting all imports from `plugin/`, `ui/`, `uiExports`, and relative imports from other plugins into a set of services that originate at runtime during plugin initialization and get passed around throughout the business logic of the plugin as function arguments. - -### Plan of action - -In order to move a legacy plugin to the new plugin system, the challenges on the server and in the browser must be addressed. Fortunately, **the hardest problems can be solved in legacy plugins today** without consuming the new plugin system at all. - -The approach and level of effort varies significantly between server and browser plugins, but at a high level the approach is the same. - -First, decouple your plugin's business logic from the dependencies that are not exposed through the new platform, hapi.js and angular.js. Then introduce plugin definitions that more accurately reflect how plugins are defined in the new platform. Finally, replace the functionality you consume from core and other plugins with their new platform equivalents. - -Once those things are finished for any given plugin, it can officially be switched to the new plugin system. - -## Server-side plan of action - -Legacy server-side plugins access functionality from core and other plugins at runtime via function arguments, which is similar to how they must be architected to use the new plugin system. This greatly simplifies the plan of action for migrating server-side plugins. - -Here is the high-level for migrating a server-side plugin: - -- De-couple from hapi.js server and request objects -- Introduce a new plugin definition shim -- Replace legacy services in shim with new platform services -- Finally, move to the new plugin system - -These steps (except for the last one) do not have to be completed strictly in order, and some can be done in parallel or as part of the same change. In general, we recommend that larger plugins approach this more methodically, doing each step in a separate change. This makes each individual change less risk and more focused. This approach may not make sense for smaller plugins. For instance, it may be simpler to switch to New Platform services when you introduce your Plugin class, rather than shimming it with the legacy service. - -### De-couple from hapi.js server and request objects - -Most integrations with core and other plugins occur through the hapi.js `server` and `request` objects, and neither of these things are exposed through the new platform, so tackle this problem first. - -Fortunately, decoupling from these objects is relatively straightforward. - -The server object is introduced to your plugin in its legacy `init` function, so in that function you will "pick" the functionality you actually use from `server` and attach it to a new interface, which you will then pass in all the places you had previously been passing `server`. - -The `request` object is introduced to your plugin in every route handler, so at the root of every route handler, you will create a new interface by "picking" the request information (e.g. body, headers) and core and plugin capabilities from the `request` object that you actually use and pass that in all the places you previously were passing `request`. - -Any calls to mutate either the server or request objects (e.g. `server.decorate()`) will be moved toward the root of the legacy `init` function if they aren't already there. - -Let's take a look at an example legacy plugin definition that uses both `server` and `request`. - -```ts -// likely imported from another file -function search(server, request) { - const { elasticsearch } = server.plugins; - return elasticsearch.getCluster('admin').callWithRequest(request, 'search'); -} - -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - server.route({ - path: '/api/demo_plugin/search', - method: 'POST', - async handler(request) { - search(server, request); // target acquired - }, - }); - - server.expose('getDemoBar', () => { - return `Demo ${server.plugins.foo.getBar()}`; - }); - }, - }); -}; -``` - -This example legacy plugin uses hapi's `server` object directly inside of its `init` function, which is something we can address in a later step. What we need to address in this step is when we pass the raw `server` and `request` objects into our custom `search` function. - -Our goal in this step is to make sure we're not integrating with other plugins via functions on `server.plugins.*` or on the `request` object. You should begin by finding all of the integration points where you make these calls, and put them behind a "facade" abstraction that can hide the details of where these APIs come from. This allows you to easily switch out how you access these APIs without having to change all of the code that may use them. - -Instead, we identify which functionality we actually need from those objects and craft custom new interfaces for them, taking care not to leak hapi.js implementation details into their design. - -```ts -import { ElasticsearchPlugin, Request } from '../elasticsearch'; -export interface ServerFacade { - plugins: { - elasticsearch: ElasticsearchPlugin; - }; -} -export interface RequestFacade extends Request {} - -// likely imported from another file -function search(server: ServerFacade, request: RequestFacade) { - const { elasticsearch } = server.plugins; - return elasticsearch.getCluster('admin').callWithRequest(request, 'search'); -} - -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - const serverFacade: ServerFacade = { - plugins: { - elasticsearch: server.plugins.elasticsearch, - }, - }; - - server.route({ - path: '/api/demo_plugin/search', - method: 'POST', - async handler(request) { - const requestFacade: RequestFacade = { - headers: request.headers, - }; - search(serverFacade, requestFacade); - }, - }); - - server.expose('getDemoBar', () => { - return `Demo ${server.plugins.foo.getBar()}`; - }); - }, - }); -}; -``` - -This change might seem trivial, but it's important for two reasons. - -First, the business logic built into `search` is now coupled to an object you created manually and have complete control over rather than hapi itself. This will allow us in a future step to replace the dependency on hapi without necessarily having to modify the business logic of the plugin. - -Second, it forced you to clearly define the dependencies you have on capabilities provided by core and by other plugins. This will help in a future step when you must replace those capabilities with services provided through the new platform. - -### Introduce new plugin definition shim - -While most plugin logic is now decoupled from hapi, the plugin definition itself still uses hapi to expose functionality for other plugins to consume and access functionality from both core and a different plugin. - -```ts -// index.ts - -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - const serverFacade: ServerFacade = { - plugins: { - elasticsearch: server.plugins.elasticsearch, - }, - }; - - // HTTP functionality from legacy - server.route({ - path: '/api/demo_plugin/search', - method: 'POST', - async handler(request) { - const requestFacade: RequestFacade = { - headers: request.headers, - }; - search(serverFacade, requestFacade); - }, - }); - - // Exposing functionality for other plugins - server.expose('getDemoBar', () => { - return `Demo ${server.plugins.foo.getBar()}`; // Accessing functionality from another plugin - }); - }, - }); -}; -``` - -We now move this logic into a new plugin definition, which is based off of the conventions used in real new platform plugins. While the legacy plugin definition is in the root of the plugin, this new plugin definition will be under the plugin's `server/` directory since it is only the server-side plugin definition. - -```ts -// server/plugin.ts -import { CoreSetup, Plugin } from 'src/core/server'; -import { ElasticsearchPlugin } from '../elasticsearch'; - -interface FooSetup { - getBar(): string; -} - -// We inject the miminal legacy dependencies into our plugin including dependencies on other legacy -// plugins. Take care to only expose the legacy functionality you need e.g. don't inject the whole -// `Legacy.Server` if you only depend on `Legacy.Server['route']`. -interface LegacySetup { - route: Legacy.Server['route']; - plugins: { - elasticsearch: ElasticsearchPlugin; // note: Elasticsearch is in CoreSetup in NP, rather than a plugin - foo: FooSetup; - }; -} - -// Define the public API's for our plugins setup and start lifecycle -export interface DemoSetup { - getDemoBar: () => string; -} -export interface DemoStart {} - -// Once we start dependending on NP plugins' setup or start API's we'll add their types here -export interface DemoSetupDeps {} -export interface DemoStartDeps {} - -export class DemoPlugin implements Plugin { - public setup(core: CoreSetup, plugins: PluginsSetup, __LEGACY: LegacySetup): DemoSetup { - // We're still using the legacy Elasticsearch and http router here, but we're now accessing - // these services in the same way a NP plugin would: injected into the setup function. It's - // also obvious that these dependencies needs to be removed by migrating over to the New - // Platform services exposed through core. - const serverFacade: ServerFacade = { - plugins: { - elasticsearch: __LEGACY.plugins.elasticsearch, - }, - }; - - __LEGACY.route({ - path: '/api/demo_plugin/search', - method: 'POST', - async handler(request) { - const requestFacade: RequestFacade = { - headers: request.headers, - }; - search(serverFacade, requestFacade); - }, - }); - - // Exposing functionality for other plugins - return { - getDemoBar() { - return `Demo ${__LEGACY.plugins.foo.getBar()}`; // Accessing functionality from another legacy plugin - }, - }; - } -} -``` - -The legacy plugin definition is still the one that is being executed, so we now "shim" this new plugin definition into the legacy world by instantiating it and wiring it up inside of the legacy `init` function. - -```ts -// index.ts - -import { Plugin, PluginDependencies, LegacySetup } from './server/plugin'; - -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - // core setup API's - const coreSetup = server.newPlatform.setup.core; - - // For now we don't have any dependencies on NP plugins - const pluginsSetup: PluginsSetup = {}; - - // legacy dependencies - const __LEGACY: LegacySetup = { - route: server.route, - plugins: { - elasticsearch: server.plugins.elasticsearch, - foo: server.plugins.foo, - }, - }; - - const demoSetup = new Plugin().setup(coreSetup, pluginsSetup, __LEGACY); - - // continue to expose functionality to legacy plugins - server.expose('getDemoBar', demoSetup.getDemoBar); - }, - }); -}; -``` - -> Note: An equally valid approach is to extend `CoreSetup` with a `__legacy` -> property instead of introducing a third parameter to your plugins lifecycle -> function. The important thing is that you reduce the legacy API surface that -> you depend on to a minimum by only picking and injecting the methods you -> require and that you clearly differentiate legacy dependencies in a namespace. - -This introduces a layer between the legacy plugin system with hapi.js and the logic you want to move to the new plugin system. The functionality exposed through that layer is still provided from the legacy world and in some cases is still technically powered directly by hapi, but building this layer forced you to identify the remaining touch points into the legacy world and it provides you with control when you start migrating to new platform-backed services. - -> Need help constructing your shim? There are some common APIs that are already present in the New Platform. In these cases, it may make more sense to simply use the New Platform service rather than crafting your own shim. Refer to the _[How do I build my shim for New Platform services?](#how-do-i-build-my-shim-for-new-platform-services)_ section for a table of legacy to new platform service translations to identify these. Note that while some APIs have simply _moved_ others are completely different. Take care when choosing how much refactoring to do in a single change. - -### Switch to new platform services - -At this point, your legacy server-side plugin is described in the shape and -conventions of the new plugin system, and all of the touch points with the -legacy world and hapi.js have been isolated inside the `__LEGACY` parameter. - -Now the goal is to replace all legacy services with services provided by the new platform instead. - -For the first time in this guide, your progress here is limited by the migration efforts within core and other plugins. - -As core capabilities are migrated to services in the new platform, they are made available as lifecycle contracts to the legacy `init` function through `server.newPlatform`. This allows you to adopt the new platform service APIs directly in your legacy plugin as they get rolled out. - -For the most part, care has been taken when migrating services to the new platform to preserve the existing APIs as much as possible, but there will be times when new APIs differ from the legacy equivalents. - -If a legacy API differs from its new platform equivalent, some refactoring will be required. The best outcome comes from updating the plugin code to use the new API, but if that's not practical now, you can also create a facade inside your new plugin definition that is shaped like the legacy API but powered by the new API. Once either of these things is done, that override can be removed from the shim. - -Eventually, all `__LEGACY` dependencies will be removed and your Plugin will -be powered entirely by Core API's from `server.newPlatform.setup.core`. - -```ts -init(server) { - // core setup API's - const coreSetup = server.newPlatform.setup.core; - - // For now we don't have any dependencies on NP plugins - const pluginsSetup: PluginsSetup = {}; - - // legacy dependencies, we've removed our dependency on elasticsearch and server.route - const __LEGACY: LegacySetup = { - plugins: { - foo: server.plugins.foo - } - }; - - const demoSetup = new Plugin().setup(coreSetup, pluginsSetup, __LEGACY); -} -``` - -At this point, your legacy server-side plugin logic is no longer coupled to -the legacy core. - -A similar approach can be taken for your plugin dependencies. To start -consuming an API from a New Platform plugin access these from -`server.newPlatform.setup.plugins` and inject it into your plugin's setup -function. - -```ts -init(server) { - // core setup API's - const coreSetup = server.newPlatform.setup.core; - - // Depend on the NP plugin 'foo' - const pluginsSetup: PluginsSetup = { - foo: server.newPlatform.setup.plugins.foo - }; - - const demoSetup = new Plugin().setup(coreSetup, pluginsSetup); -} -``` - -As the plugins you depend on are migrated to the new platform, their contract -will be exposed through `server.newPlatform`, so the `__LEGACY` dependencies -should be removed. Like in core, plugins should take care to preserve their -existing APIs to make this step as seamless as possible. - -It is much easier to reliably make breaking changes to plugin APIs in the new -platform than it is in the legacy world, so if you're planning a big change, -consider doing it after your dependent plugins have migrated rather than as -part of your own migration. - -Eventually, all `__LEGACY` dependencies will be removed and your plugin will be -entirely powered by the New Platform and New Platform plugins. - -> Note: All New Platform plugins are exposed to legacy plugins via -> `server.newPlatform.setup.plugins`. Once you move your plugin over to the -> New Platform you will have to explicitly declare your dependencies on other -> plugins in your `kibana.json` manifest file. - -At this point, your legacy server-side plugin logic is no longer coupled to legacy plugins. - -### Migrate to the new plugin system - -With both shims converted, you are now ready to complete your migration to the new platform. - -Many plugins will copy and paste all of their plugin code into a new plugin directory in either `src/plugins` for OSS or `x-pack/plugins` for commerical code and then delete their legacy shims. It's at this point that you'll want to make sure to create your `kibana.json` file if it does not already exist. - -With the previous steps resolved, this final step should be easy, but the exact process may vary plugin by plugin, so when you're at this point talk to the platform team to figure out the exact changes you need. - -Other plugins may want to move subsystems over individually. For instance, you can move routes over to the New Platform in groups rather than all at once. Other examples that could be broken up: - -- Configuration schema ([see example](./MIGRATION_EXAMPLES.md#declaring-config-schema)) -- HTTP route registration ([see example](./MIGRATION_EXAMPLES.md#http-routes)) -- Polling mechanisms (eg. job worker) - -In general, we recommend moving all at once by ensuring you're not depending on any legacy code before you move over. - -## Browser-side plan of action - -It is generally a much greater challenge preparing legacy browser-side code for the new platform than it is server-side, and as such there are a few more steps. The level of effort here is proportional to the extent to which a plugin is dependent on angular.js. - -To complicate matters further, a significant amount of the business logic in Kibana's client-side code exists inside the `ui/public` directory (aka ui modules), and all of that must be migrated as well. Unlike the server-side code where the order in which you migrated plugins was not particularly important, it's important that UI modules be addressed as soon as possible. - -Because usage of angular and `ui/public` modules varies widely between legacy plugins, there is no "one size fits all" solution to migrating your browser-side code to the new platform. The best place to start is by checking with the platform team to help identify the best migration path for your particular plugin. - -That said, we've seen a series of patterns emerge as teams begin migrating browser code. In practice, most migrations will follow a path that looks something like this: - -#### 1. Create a plugin definition file - -We've found that doing this right away helps you start thinking about your plugin in terms of lifecycle methods and services, which makes the rest of the migration process feel more natural. It also forces you to identify which actions "kick off" your plugin, since you'll need to execute those when the `setup/start` methods are called. - -This definition isn't going to do much for us just yet, but as we get further into the process, we will gradually start returning contracts from our `setup` and `start` methods, while also injecting dependencies as arguments to these methods. - -```ts -// public/plugin.ts -import { CoreSetup, CoreStart, Plugin } from 'kibana/server'; -import { FooSetup, FooStart } from '../../../../legacy/core_plugins/foo/public'; - -/** - * These are the private interfaces for the services your plugin depends on. - * @internal - */ -export interface DemoSetupDeps { - foo: FooSetup; -} -export interface DemoStartDeps { - foo: FooStart; -} - -/** - * These are the interfaces with your public contracts. You should export these - * for other plugins to use in _their_ `SetupDeps`/`StartDeps` interfaces. - * @public - */ -export type DemoSetup = {}; -export type DemoStart = {}; - -/** @internal */ -export class DemoPlugin implements Plugin { - public setup(core: CoreSetup, plugins: DemoSetupDeps): DemoSetup { - // kick off your plugin here... - return { - fetchConfig: () => ({}), - }; - } - - public start(core: CoreStart, plugins: DemoStartDeps): DemoStart { - // ...or here - return { - initDemo: () => ({}), - }; - } - - public stop() {} -} -``` - -#### 2. Export all static code and types from `public/index.ts` - -If your plugin needs to share static code with other plugins, this code must be exported from your top-level `public/index.ts`. This includes any type interfaces that you wish to make public. For details on the types of code that you can safely share outside of the runtime lifecycle contracts, see [Can static code be shared between plugins?](#can-static-code-be-shared-between-plugins) - -```ts -// public/index.ts -import { DemoSetup, DemoStart } from './plugin'; - -const myPureFn = (x: number): number => x + 1; -const MyReactComponent = (props) => { - return

Hello, {props.name}

; -}; - -// These are your public types & static code -export { myPureFn, MyReactComponent, DemoSetup, DemoStart }; -``` - -While you're at it, you can also add your plugin initializer to this file: - -```ts -// public/index.ts -import { PluginInitializer, PluginInitializerContext } from 'kibana/server'; -import { DemoSetup, DemoStart, DemoSetupDeps, DemoStartDeps, DemoPlugin } from './plugin'; - -// Core will be looking for this when loading our plugin in the new platform -export const plugin: PluginInitializer = ( - initializerContext: PluginInitializerContext -) => { - return new DemoPlugin(); -}; - -const myPureFn = (x: number): number => x + 1; -const MyReactComponent = (props) => { - return

Hello, {props.name}

; -}; - -/** @public */ -export { myPureFn, MyReactComponent, DemoSetup, DemoStart }; -``` - -Great! So you have your plugin definition, and you've moved all of your static exports to the top level of your plugin... now let's move on to the runtime contract your plugin will be exposing. - -#### 3. Export your runtime contract - -Next, we need a way to expose your runtime dependencies. In the new platform, core will handle this for you. But while we are still in the legacy world, other plugins will need a way to consume your plugin's contract without the help of core. - -So we will take a similar approach to what was described above in the server section: actually call the `Plugin.setup()` and `Plugin.start()` methods, and export the values those return for other legacy plugins to consume. By convention, we've been placing this in a `legacy.ts` file, which also serves as our shim where we import our legacy dependencies and reshape them into what we are expecting in the new platform: - -```ts -// public/legacy.ts -import { PluginInitializerContext } from 'kibana/server'; -import { npSetup, npStart } from 'ui/new_platform'; -import { plugin } from '.'; - -import { setup as fooSetup, start as fooStart } from '../../foo/public/legacy'; // assumes `foo` lives in `legacy/core_plugins` - -const pluginInstance = plugin({} as PluginInitializerContext); -const __LEGACYSetup = { - bar: {}, // shim for a core service that hasn't migrated yet - foo: fooSetup, // dependency on a legacy plugin -}; -const __LEGACYStart = { - bar: {}, // shim for a core service that hasn't migrated yet - foo: fooStart, // dependency on a legacy plugin -}; - -export const setup = pluginInstance.setup(npSetup.core, npSetup.plugins, __LEGACYSetup); -export const start = pluginInstance.start(npStart.core, npStart.plugins, __LEGACYStart); -``` - -> As you build your shims, you may be wondering where you will find some legacy services in the new platform. Skip to [the tables below](#how-do-i-build-my-shim-for-new-platform-services) for a list of some of the more common legacy services and where we currently expect them to live. - -Notice how in the example above, we are importing the `setup` and `start` contracts from the legacy shim provided by `foo` plugin; we could just as easily be importing modules from `ui/public` here as well. - -The point is that, over time, this becomes the one file in our plugin containing stateful imports from the legacy world. And _that_ is where things start to get interesting... - -#### 4. Move "owned" UI modules into your plugin and expose them from your public contract - -Everything inside of the `ui/public` directory is going to be dealt with in one of the following ways: - -- Deleted because it doesn't need to be used anymore -- Moved to or replaced by something in core that isn't coupled to angular -- Moved to or replaced by an extension point in a specific plugin that "owns" that functionality -- Copied into each plugin that depends on it and becomes an implementation detail there - -To rapidly define ownership and determine interdependencies, UI modules should move to the most appropriate plugins to own them. Modules that are considered "core" can remain in the ui directory as the platform team works to move them out. - -Concerns around ownership or duplication of a given module should be raised and resolved with the appropriate team so that the code is either duplicated to break the interdependency or a team agrees to "own" that extension point in one of their plugins and the module moves there. - -A great outcome is a module being deleted altogether because it isn't used or it was used so lightly that it was easy to refactor away. - -If it is determined that your plugin is going to own any UI modules that other plugins depend on, you'll want to migrate these quickly so that there's time for downstream plugins to update their imports. This will ultimately involve moving the module code into your plugin, and exposing it via your setup/start contracts, or as static code from your `plugin/index.ts`. We have identified owners for most of the legacy UI modules; if you aren't sure where you should move something that you own, please consult with the platform team. - -Depending on the module's level of complexity and the number of other places in Kibana that rely on it, there are a number of strategies you could use for this: - -- **Do it all at once.** Move the code, expose it from your plugin, and update all imports across Kibana. - - This works best for small pieces of code that aren't widely used. -- **Shim first, move later.** Expose the code from your plugin by importing it in your shim and then re-exporting it from your plugin first, then gradually update imports to pull from the new location, leaving the actual moving of the code as a final step. - - This works best for the largest, most widely used modules that would otherwise result in huge, hard-to-review PRs. - - It makes things easier by splitting the process into small, incremental PRs, but is probably overkill for things with a small surface area. -- **Hybrid approach.** As a middle ground, you can also move the code to your plugin immediately, and then re-export your plugin code from the original `ui/public` directory. - - This eliminates any concerns about backwards compatibility by allowing you to update the imports across Kibana later. - - Works best when the size of the PR is such that moving the code can be done without much refactoring. - -#### 5. Provide plugin extension points decoupled from angular.js - -There will be no global angular module in the new platform, which means none of the functionality provided by core will be coupled to angular. Since there is no global angular module shared by all applications, plugins providing extension points to be used by other plugins can not couple those extension points to angular either. - -All teams that own a plugin are strongly encouraged to remove angular entirely, but if nothing else they must provide non-angular-based extension points for plugins. - -One way to address this problem is to go through the code that is currently exposed to plugins and refactor away all of the touch points into angular.js. This might be the easiest option in some cases, but it might be hard in others. - -Another way to address this problem is to create an entirely new set of plugin APIs that are not dependent on angular.js, and then update the implementation within the plugin to "merge" the angular and non-angular capabilities together. This is a good approach if preserving the existing angular API until we remove the old plugin system entirely is of critical importance. Generally speaking though, the removal of angular and introduction of a new set of public plugin APIs is a good reason to make a breaking change to the existing plugin capabilities. Make sure the PRs are tagged appropriately so we add these changes to our plugin changes blog post for each release. - -Please talk with the platform team when formalizing _any_ client-side extension points that you intend to move to the new platform as there are some bundling considerations to consider. - -#### 6. Move all webpack alias imports into uiExport entry files - -Existing plugins import three things using webpack aliases today: services from ui/public (`ui/`), services from other plugins (`plugins/`), and uiExports themselves (`uiExports/`). These webpack aliases will not exist once we remove the legacy plugin system, so part of our migration effort is addressing all of the places where they are used today. - -In the new platform, dependencies from core and other plugins will be passed through lifecycle functions in the plugin definition itself. In a sense, they will be run from the "root" of the plugin. - -With the legacy plugin system, extensions of core and other plugins are handled through entry files defined as uiExport paths. In other words, when a plugin wants to serve an application (a core-owned thing), it defines a main entry file for the app via the `app` uiExport, and when a plugin wants to extend visTypes (a plugin-owned thing), they do so by specifying an entry file path for the `visType` uiExport. - -Each uiExport path is an entry file into one specific set of functionality provided by a client-side plugin. All webpack alias-based imports should be moved to these entry files, where they are appropriate. Moving a deeply nested webpack alias-based import in a plugin to one of the uiExport entry files might require some refactoring to ensure the dependency is now passed down to the appropriate place as function arguments instead of via import statements. - -For stateful dependencies using the `plugins/` and `ui/` webpack aliases, you should be able to take advantage of the `legacy.ts` shim you created earlier. By placing these imports directly in your shim, you can pass the dependencies you need into your `Plugin.start` and `Plugin.setup` methods, from which point they can be passed down to the rest of your plugin's entry files. - -For items that don't yet have a clear "home" in the new platform, it may also be helpful to somehow indicate this in your shim to make it easier to remember that you'll need to change this later. One convention we've found helpful for this is simply using a namespace like `__LEGACY`: - -```ts -// public/legacy.ts -import { uiThing } from 'ui/thing'; -... - -const pluginInstance = plugin({} as PluginInitializerContext); -const __LEGACY = { - foo: fooSetup, - uiThing, // eventually this will move out of __LEGACY and into a NP plugin -}; - -... -export const setup = pluginInstance.setup(npSetup.core, npSetup.plugins, __LEGACY); -``` - -#### 7. Switch to new platform services - -At this point, your plugin has one or more uiExport entry files that together contain all of the webpack alias-based import statements needed to run your plugin. Each one of these import statements is either a service that is or will be provided by core or a service provided by another plugin. - -As new non-angular-based APIs are added, update your entry files to import the correct service API. The service APIs provided directly from the new platform can be imported through the `ui/new_platform` module for the duration of this migration. As new services are added, they will also be exposed there. This includes all core services as well as any APIs provided by real new platform plugins. - -Once all of the existing webpack alias-based imports in your plugin switch to `ui/new_platform`, it no longer depends directly on the legacy "core" features or other legacy plugins, so it is ready to officially migrate to the new platform. - -#### 8. Migrate to the new plugin system - -With all of your services converted, you are now ready to complete your migration to the new platform. - -Many plugins at this point will copy over their plugin definition class & the code from their various service/uiExport entry files directly into the new plugin directory. The `legacy.ts` shim file can then simply be deleted. - -With the previous steps resolved, this final step should be easy, but the exact process may vary plugin by plugin, so when you're at this point talk to the platform team to figure out the exact changes you need. - -Other plugins may want to move subsystems over individually. Examples of pieces that could be broken up: - -- Registration logic (eg. viz types, embeddables, chrome nav controls) -- Application mounting -- Polling mechanisms (eg. job worker) - -#### Bonus: Tips for complex migration scenarios - -For a few plugins, some of these steps (such as angular removal) could be a months-long process. In those cases, it may be helpful from an organizational perspective to maintain a clear separation of code that is and isn't "ready" for the new platform. - -One convention that is useful for this is creating a dedicated `public/np_ready` directory to house the code that is ready to migrate, and gradually move more and more code into it until the rest of your plugin is essentially empty. At that point, you'll be able to copy your `index.ts`, `plugin.ts`, and the contents of `./np_ready` over into your plugin in the new platform, leaving your legacy shim behind. This carries the added benefit of providing a way for us to introduce helpful tooling in the future, such as [custom eslint rules](https://github.com/elastic/kibana/pull/40537), which could be run against that specific directory to ensure your code is ready to migrate. - -## Keep Kibana fast - -**tl;dr**: Load as much code lazily as possible. -Everyone loves snappy applications with responsive UI and hates spinners. Users deserve the best user experiences regardless of whether they run Kibana locally or in the cloud, regardless of their hardware & environment. -There are 2 main aspects of the perceived speed of an application: loading time and responsiveness to user actions. -New platform loads and bootstraps **all** the plugins whenever a user lands on any page. It means that adding every new application affects overall **loading performance** in the new platform, as plugin code is loaded **eagerly** to initialize the plugin and provide plugin API to dependent plugins. -However, it's usually not necessary that the whole plugin code should be loaded and initialized at once. The plugin could keep on loading code covering API functionality on Kibana bootstrap but load UI related code lazily on-demand, when an application page or management section is mounted. -Always prefer to require UI root components lazily when possible (such as in mount handlers). Even if their size may seem negligible, they are likely using some heavy-weight libraries that will also be removed from the initial plugin bundle, therefore, reducing its size by a significant amount. - -```typescript -import { Plugin, CoreSetup, AppMountParameters } from 'src/core/public'; -export class MyPlugin implements Plugin { - setup(core: CoreSetup, plugins: SetupDeps) { - core.application.register({ - id: 'app', - title: 'My app', - async mount(params: AppMountParameters) { - const { mountApp } = await import('./app/mount_app'); - return mountApp(await core.getStartServices(), params); - }, - }); - plugins.management.sections.section.kibana.registerApp({ - id: 'app', - title: 'My app', - order: 1, - async mount(params) { - const { mountManagementSection } = await import('./app/mount_management_section'); - return mountManagementSection(coreSetup, params); - }, - }); - return { - doSomething() {}, - }; - } -} -``` - -#### How to understand how big the bundle size of my plugin is? - -New platform plugins are distributed as a pre-built with `@kbn/optimizer` package artifacts. It allows us to get rid of the shipping of `optimizer` in the distributable version of Kibana. -Every NP plugin artifact contains all plugin dependencies required to run the plugin, except some stateful dependencies shared across plugin bundles via `@kbn/ui-shared-deps`. -It means that NP plugin artifacts tend to have a bigger size than the legacy platform version. -To understand the current size of your plugin artifact, run `@kbn/optimizer` as - -```bash -node scripts/build_kibana_platform_plugins.js --dist --profile --focus=my_plugin -``` - -and check the output in the `target` sub-folder of your plugin folder - -```bash -ls -lh plugins/my_plugin/target/public/ -# output -# an async chunk loaded on demand -... 262K 0.plugin.js -# eagerly loaded chunk -... 50K my_plugin.plugin.js -``` - -you might see at least one js bundle - `my_plugin.plugin.js`. This is the only artifact loaded by the platform during bootstrap in the browser. The rule of thumb is to keep its size as small as possible. -Other lazily loaded parts of your plugin present in the same folder as separate chunks under `{number}.plugin.js` names. -If you want to investigate what your plugin bundle consists of you need to run `@kbn/optimizer` with `--profile` flag to get generated [webpack stats file](https://webpack.js.org/api/stats/). - -```bash -node scripts/build_kibana_platform_plugins.js --dist --no-examples --profile -``` - -Many OSS tools are allowing you to analyze generated stats file - -- [an official tool](http://webpack.github.io/analyse/#modules) from webpack authors -- [webpack-visualizer](https://chrisbateman.github.io/webpack-visualizer/) - -## Frequently asked questions - -### Is migrating a plugin an all-or-nothing thing? - -It doesn't have to be. Within the Kibana repo, you can have a new platform plugin with the same name as a legacy plugin. - -Technically speaking, you could move all of your server-side code to the new platform and leave the legacy browser-side code where it is. You can even move only a portion of code on your server at a time, like on a route by route basis for example. - -For any new plugin APIs being defined as part of this process, it is recommended to create those APIs in new platform plugins, and then core will pass them down into the legacy world to be used there. This leaves one less thing you need to migrate. - -### Do plugins need to be converted to TypeScript? - -No. That said, the migration process will require a lot of refactoring, and TypeScript will make this dramatically easier and less risky. Independent of the new platform effort, our goals are to convert the entire Kibana repo to TypeScript over time, so now is a great time to do it. - -At the very least, any plugin exposing an extension point should do so with first-class type support so downstream plugins that _are_ using TypeScript can depend on those types. - -### Can static code be shared between plugins? - -**tl;dr** Yes, but it should be limited to pure functional code that does not depend on outside state from the platform or a plugin. - -#### Background - -> Don't care why, just want to know how? Skip to the ["how" section below](#how-to-decide-what-code-can-be-statically-imported). - -Legacy Kibana has never run as a single page application. Each plugin has it's own entry point and gets "ownership" of every module it imports when it is loaded into the browser. This has allowed stateful modules to work without breaking other plugins because each time the user navigates to a new plugin, the browser reloads with a different entry bundle, clearing the state of the previous plugin. - -Because of this "feature" many undesirable things developed in the legacy platform: - -- We had to invent an unconventional and fragile way of allowing plugins to integrate and communicate with one another, `uiExports`. -- It has never mattered if shared modules in `ui/public` were stateful or cleaned up after themselves, so many of them behave like global singletons. These modules could never work in single-page application because of this state. -- We've had to ship Webpack with Kibana in production so plugins could be disabled or installed and still have access to all the "platform" features of `ui/public` modules and all the `uiExports` would be present for any enabled plugins. -- We've had to require that 3rd-party plugin developers release a new version of their plugin for each and every version of Kibana because these shared modules have no stable API and are coupled tightly both to their consumers and the Kibana platform. - -The New Platform's primary goal is to make developing Kibana plugins easier, both for developers at Elastic and in the community. The approach we've chosen is to enable plugins to integrate and communicate _at runtime_ rather than at build time. By wiring services and plugins up at runtime, we can ship stable APIs that do not have to be compiled into every plugin and instead live inside a solid core that each plugin gets connected to when it executes. - -This applies to APIs that plugins expose as well. In the new platform, plugins can communicate through an explicit interface rather than importing all the code from one another and having to recompile Webpack bundles when a plugin is disabled or a new plugin is installed. - -You've probably noticed that this is not the typical way a JavaScript developer works. We're used to importing code at the top of files (and for some use-cases this is still fine). However, we're not building a typical JavaScript application, we're building an application that is installed into a dynamic system (the Kibana Platform). - -#### What goes wrong if I do share modules with state? - -One goal of a stable Kibana core API is to allow Kibana instances to run plugins with varying minor versions, e.g. Kibana 8.4.0 running PluginX 8.0.1 and PluginY 8.2.5. This will be made possible by building each plugin into an “immutable bundle” that can be installed into Kibana. You can think of an immutable bundle as code that doesn't share any imported dependencies with any other bundles, that is all it's dependencies are bundled together. - -This method of building and installing plugins comes with side effects which are important to be aware of when developing a plugin. - -- **Any code you export to other plugins will get copied into their bundles.** If a plugin is built for 8.1 and is running on Kibana 8.2, any modules it imported that changed will not be updated in that plugin. -- **When a plugin is disabled, other plugins can still import its static exports.** This can make code difficult to reason about and result in poor user experience. For example, users generally expect that all of a plugin’s features will be disabled when the plugin is disabled. If another plugin imports a disabled plugin’s feature and exposes it to the user, then users will be confused about whether that plugin really is disabled or not. -- **Plugins cannot share state by importing each others modules.** Sharing state via imports does not work because exported modules will be copied into plugins that import them. Let’s say your plugin exports a module that’s imported by other plugins. If your plugin populates state into this module, a natural expectation would be that the other plugins now have access to this state. However, because those plugins have copies of the exported module, this assumption will be incorrect. - -#### How to decide what code can be statically imported - -The general rule of thumb here is: any module that is not purely functional should not be shared statically, and instead should be exposed at runtime via the plugin's `setup` and/or `start` contracts. - -Ask yourself these questions when deciding to share code through static exports or plugin contracts: - -- Is its behavior dependent on any state populated from my plugin? -- If a plugin uses an old copy (from an older version of Kibana) of this module, will it still break? - -If you answered yes to any of the above questions, you probably have an impure module that cannot be shared across plugins. Another way to think about this: if someone literally copied and pasted your exported module into their plugin, would it break if: - -- Your original module changed in a future version and the copy was the old version; or -- If your plugin doesn’t have access to the copied version in the other plugin (because it doesn't know about it). - -If your module were to break for either of these reasons, it should not be exported statically. This can be more easily illustrated by examples of what can and cannot be exported statically. - -Examples of code that could be shared statically: - -- Constants. Strings and numbers that do not ever change (even between Kibana versions) - - If constants do change between Kibana versions, then they should only be exported statically if the old value would not _break_ if it is still used. For instance, exporting a constant like `VALID_INDEX_NAME_CHARACTERS` would be fine, but exporting a constant like `API_BASE_PATH` would not because if this changed, old bundles using the previous value would break. -- React components that do not depend on module state. - - Make sure these components are not dependent on or pre-wired to Core services. In many of these cases you can export a HOC that takes the Core service and returns a component wired up to that particular service instance. - - These components do not need to be "pure" in the sense that they do not use React state or React hooks, they just cannot rely on state inside the module or any modules it imports. -- Pure computation functions, for example lodash-like functions like `mapValues`. - -Examples of code that could **not** be shared statically and how to fix it: - -- A function that calls a Core service, but does not take that service as a parameter. - - - If the function does not take a client as an argument, it must have an instance of the client in its internal state, populated by your plugin. This would not work across plugin boundaries because your plugin would not be able to call `setClient` in the copy of this module in other plugins: - - ```js - let esClient; - export const setClient = (client) => (esClient = client); - export const query = (params) => esClient.search(params); - ``` - - - This could be fixed by requiring the calling code to provide the client: - - ```js - export const query = (esClient, params) => esClient.search(params); - ``` - -- A function that allows other plugins to register values that get pushed into an array defined internally to the module. - - - The values registered would only be visible to the plugin that imported it. Each plugin would essentially have their own registry of visTypes that is not visible to any other plugins. - - ```js - const visTypes = []; - export const registerVisType = (visType) => visTypes.push(visType); - export const getVisTypes = () => visTypes; - ``` - - - For state that does need to be shared across plugins, you will need to expose methods in your plugin's `setup` and `start` contracts. - - ```js - class MyPlugin { - constructor() { - this.visTypes = []; - } - setup() { - return { - registerVisType: (visType) => this.visTypes.push(visType), - }; - } - - start() { - return { - getVisTypes: () => this.visTypes, - }; - } - } - ``` - -In any case, you will also need to carefully consider backward compatibility (BWC). Whatever you choose to export will need to work for the entire major version cycle (eg. Kibana 8.0-8.9), regardless of which version of the export a plugin has bundled and which minor version of Kibana they're using. Breaking changes to static exports are only allowed in major versions. However, during the 7.x cycle, all of these APIs are considered "experimental" and can be broken at any time. We will not consider these APIs stable until 8.0 at the earliest. - -#### Concrete Example - -Ok, you've decided you want to export static code from your plugin, how do you do it? The New Platform only considers values exported from `my_plugin/public` and `my_plugin/server` to be stable. The linter will only let you import statically from these top-level modules. In the future, our tooling will enforce that these APIs do not break between minor versions. All code shared among plugins should be exported in these modules like so: - -```ts -// my_plugin/public/index.ts -export { MyPureComponent } from './components'; - -// regular plugin export used by core to initialize your plugin -export const plugin = ...; -``` - -These can then be imported using relative paths from other plugins: - -```ts -// my_other_plugin/public/components/my_app.ts -import { MyPureComponent } from '../my_plugin/public'; -``` - -If you have code that should be available to other plugins on both the client and server, you can have a common directory. _See [How is "common" code shared on both the client and server?](#how-is-common-code-shared-on-both-the-client-and-server)_ - -### How can I avoid passing Core services deeply within my UI component tree? - -There are some Core services that are purely presentational, for example `core.overlays.openModal()` or `core.application.createLink()` where UI code does need access to these deeply within your application. However, passing these services down as props throughout your application leads to lots of boilerplate. To avoid this, you have three options: - -1. Use an abstraction layer, like Redux, to decouple your UI code from core (**this is the highly preferred option**); or - - [redux-thunk](https://github.com/reduxjs/redux-thunk#injecting-a-custom-argument) and [redux-saga](https://redux-saga.js.org/docs/api/#createsagamiddlewareoptions) already have ways to do this. -2. Use React Context to provide these services to large parts of your React tree; or -3. Create a high-order-component that injects core into a React component; or - - This would be a stateful module that holds a reference to Core, but provides it as props to components with a `withCore(MyComponent)` interface. This can make testing components simpler. (Note: this module cannot be shared across plugin boundaries, see above). -4. Create a global singleton module that gets imported into each module that needs it. (Note: this module cannot be shared across plugin boundaries, see above). [Example](https://gist.github.com/epixa/06c8eeabd99da3c7545ab295e49acdc3). - -If you find that you need many different Core services throughout your application, this may be a code smell and could lead to pain down the road. For instance, if you need access to an HTTP Client or SavedObjectsClient in many places in your React tree, it's likely that a data layer abstraction (like Redux) could make developing your plugin much simpler (see option 1). - -Without such an abstraction, you will need to mock out Core services throughout your test suite and will couple your UI code very tightly to Core. However, if you can contain all of your integration points with Core to Redux middleware and/or reducers, you only need to mock Core services once, and benefit from being able to change those integrations with Core in one place rather than many. This will become incredibly handy when Core APIs have breaking changes. - -### How is "common" code shared on both the client and server? - -There is no formal notion of "common" code that can safely be imported from either client-side or server-side code. However, if a plugin author wishes to maintain a set of code in their plugin in a single place and then expose it to both server-side and client-side code, they can do so by exporting in the index files for both the `server` and `public` directories. - -Plugins should not ever import code from deeply inside another plugin (eg. `my_plugin/public/components`) or from other top-level directories (eg. `my_plugin/common/constants`) as these are not checked for breaking changes and are considered unstable and subject to change at any time. You can have other top-level directories like `my_plugin/common`, but our tooling will not treat these as a stable API and linter rules will prevent importing from these directories _from outside the plugin_. - -The benefit of this approach is that the details of where code lives and whether it is accessible in multiple runtimes is an implementation detail of the plugin itself. A plugin consumer that is writing client-side code only ever needs to concern themselves with the client-side contracts being exposed, and the same can be said for server-side contracts on the server. - -A plugin author that decides some set of code should diverge from having a single "common" definition can now safely change the implementation details without impacting downstream consumers. - -_See all [conventions for first-party Elastic plugins](./CONVENTIONS.md)_. - -### When does code go into a plugin, core, or packages? - -This is an impossible question to answer definitively for all circumstances. For each time this question is raised, we must carefully consider to what extent we think that code is relevant to almost everyone developing in Kibana, what license the code is shipping under, which teams are most appropriate to "own" that code, is the code stateless etc. - -As a general rule of thumb, most code in Kibana should exist in plugins. Plugins are the most obvious way that we break Kibana down into sets of specialized domains with controls around interdependency communication and management. It's always possible to move code from a plugin into core if we ever decide to do so, but it's much more disruptive to move code from core to a plugin. - -There is essentially no code that _can't_ exist in a plugin. When in doubt, put the code in a plugin. - -After plugins, core is where most of the rest of the code in Kibana will exist. Functionality that's critical to the reliable execution of the Kibana process belongs in core. Services that will widely be used by nearly every non-trivial plugin in any Kibana install belong in core. Functionality that is too specialized to specific use cases should not be in core, so while something like generic saved objects is a core concern, index patterns are not. - -The packages directory should have the least amount of code in Kibana. Just because some piece of code is not stateful doesn't mean it should go into packages. The packages directory exists to aid us in our quest to centralize as many of our owned dependencies in this single monorepo, so it's the logical place to put things like Kibana specific forks of node modules or vendor dependencies. - -### How do I build my shim for New Platform services? - -Many of the utilities you're using to build your plugins are available in the New Platform or in New Platform plugins. To help you build the shim for these new services, use the tables below to find where the New Platform equivalent lives. - -#### Client-side - -TODO: add links to API docs on items in "New Platform" column. - -##### Core services - -In client code, `core` can be imported in legacy plugins via the `ui/new_platform` module. - -```ts -import { npStart: { core } } from 'ui/new_platform'; -``` - -| Legacy Platform | New Platform | Notes | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `chrome.addBasePath` | [`core.http.basePath.prepend`](/docs/development/core/public/kibana-plugin-core-public.httpsetup.basepath.md) | | -| `chrome.navLinks.update` | [`core.appbase.updater`](/docs/development/core/public/kibana-plugin-core-public.appbase.updater_.md) | Use the `updater$` property when registering your application via `core.application.register` | -| `chrome.breadcrumbs.set` | [`core.chrome.setBreadcrumbs`](/docs/development/core/public/kibana-plugin-core-public.chromestart.setbreadcrumbs.md) | | -| `chrome.getUiSettingsClient` | [`core.uiSettings`](/docs/development/core/public/kibana-plugin-core-public.uisettingsclient.md) | | -| `chrome.helpExtension.set` | [`core.chrome.setHelpExtension`](/docs/development/core/public/kibana-plugin-core-public.chromestart.sethelpextension.md) | | -| `chrome.setVisible` | [`core.chrome.setIsVisible`](/docs/development/core/public/kibana-plugin-core-public.chromestart.setisvisible.md) | | -| `chrome.setRootTemplate` / `chrome.setRootController` | -- | Use application mounting via `core.application.register` (not available to legacy plugins at this time). | -| `import { recentlyAccessed } from 'ui/persisted_log'` | [`core.chrome.recentlyAccessed`](/docs/development/core/public/kibana-plugin-core-public.chromerecentlyaccessed.md) | | -| `ui/capabilities` | [`core.application.capabilities`](/docs/development/core/public/kibana-plugin-core-public.capabilities.md) | | -| `ui/documentation_links` | [`core.docLinks`](/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md) | | -| `ui/kfetch` | [`core.http`](/docs/development/core/public/kibana-plugin-core-public.httpservicebase.md) | API is nearly identical | -| `ui/notify` | [`core.notifications`](/docs/development/core/public/kibana-plugin-core-public.notificationsstart.md) and [`core.overlays`](/docs/development/core/public/kibana-plugin-core-public.overlaystart.md) | Toast messages are in `notifications`, banners are in `overlays`. May be combined later. | -| `ui/routes` | -- | There is no global routing mechanism. Each app [configures its own routing](/rfcs/text/0004_application_service_mounting.md#complete-example). | -| `ui/saved_objects` | [`core.savedObjects`](/docs/development/core/public/kibana-plugin-core-public.savedobjectsstart.md) | Client API is the same | -| `ui/doc_title` | [`core.chrome.docTitle`](/docs/development/core/public/kibana-plugin-core-public.chromedoctitle.md) | | -| `uiExports/injectedVars` / `chrome.getInjected` | [Configure plugin](#configure-plugin) and [`PluginConfigDescriptor.exposeToBrowser`](/docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.exposetobrowser.md) | Can only be used to expose configuration properties | - -_See also: [Public's CoreStart API Docs](/docs/development/core/public/kibana-plugin-core-public.corestart.md)_ - -##### Plugins for shared application services - -In client code, we have a series of plugins which house shared application services which are not technically part of `core`, but are often used in Kibana plugins. - -This table maps some of the most commonly used legacy items to their new platform locations. - -```ts -import { npStart: { plugins } } from 'ui/new_platform'; -``` - -| Legacy Platform | New Platform | Notes | -| ------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -| `import 'ui/apply_filters'` | N/A. Replaced by triggering an APPLY_FILTER_TRIGGER trigger. | Directive is deprecated. | -| `import 'ui/filter_bar'` | `import { FilterBar } from '../data/public'` | Directive is deprecated. | -| `import 'ui/query_bar'` | `import { QueryStringInput } from '../data/public'` | Directives are deprecated. | -| `import 'ui/search_bar'` | `import { SearchBar } from '../data/public'` | Directive is deprecated. | -| `import 'ui/kbn_top_nav'` | `import { TopNavMenu } from '../navigation/public'` | Directive was removed. | -| `ui/saved_objects/components/saved_object_finder` | `import { SavedObjectFinder } from '../saved_objects/public'` | | -| `core_plugins/interpreter` | `plugins.data.expressions` | -| `ui/courier` | `plugins.data.search` | -| `ui/agg_types` | `plugins.data.search.aggs` | Most code is available for static import. Stateful code is part of the `search` service. -| `ui/embeddable` | `plugins.embeddables` | -| `ui/filter_manager` | `plugins.data.filter` | -- | -| `ui/index_patterns` | `plugins.data.indexPatterns` | -| `import 'ui/management'` | `plugins.management.sections` | | -| `import 'ui/registry/field_format_editors'` | `plugins.indexPatternManagement.fieldFormatEditors` | | -| `ui/registry/field_formats` | `plugins.data.fieldFormats` | | -| `ui/registry/feature_catalogue` | `plugins.home.featureCatalogue.register` | Must add `home` as a dependency in your kibana.json. | -| `ui/registry/vis_types` | `plugins.visualizations` | -- | -| `ui/vis` | `plugins.visualizations` | -- | -| `ui/share` | `plugins.share` | `showShareContextMenu` is now called `toggleShareContextMenu`, `ShareContextMenuExtensionsRegistryProvider` is now called `register` | -| `ui/vis/vis_factory` | `plugins.visualizations` | -- | -| `ui/vis/vis_filters` | `plugins.visualizations.filters` | -- | -| `ui/utils/parse_es_interval` | `import { search: { aggs: { parseEsInterval } } } from '../data/public'` | `parseEsInterval`, `ParsedInterval`, `InvalidEsCalendarIntervalError`, `InvalidEsIntervalFormatError` items were moved to the `Data Plugin` as a static code | - -#### Server-side - -##### Core services - -In server code, `core` can be accessed from either `server.newPlatform` or `kbnServer.newPlatform`. There are not currently very many services available on the server-side: - -| Legacy Platform | New Platform | Notes | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `server.config()` | [`initializerContext.config.create()`](/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md) | Must also define schema. See _[how to configure plugin](#configure-plugin)_ | -| `server.route` | [`core.http.createRouter`](/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.createrouter.md) | [Examples](./MIGRATION_EXAMPLES.md#route-registration) | -| `server.renderApp()` | [`response.renderCoreApp()`](docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.rendercoreapp.md) | [Examples](./MIGRATION_EXAMPLES.md#render-html-content) | -| `server.renderAppWithDefaultConfig()` | [`response.renderAnonymousCoreApp()`](docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderanonymouscoreapp.md) | [Examples](./MIGRATION_EXAMPLES.md#render-html-content) | -| `request.getBasePath()` | [`core.http.basePath.get`](/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.basepath.md) | | -| `server.plugins.elasticsearch.getCluster('data')` | [`context.core.elasticsearch.dataClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | | -| `server.plugins.elasticsearch.getCluster('admin')` | [`context.core.elasticsearch.adminClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | | -| `server.plugins.elasticsearch.createCluster(...)` | [`core.elasticsearch.legacy.createClient`](/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.legacy.md) | | -| `server.savedObjects.setScopedSavedObjectsClientFactory` | [`core.savedObjects.setClientFactoryProvider`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md) | | -| `server.savedObjects.addScopedSavedObjectsClientWrapperFactory` | [`core.savedObjects.addClientWrapper`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.addclientwrapper.md) | | -| `server.savedObjects.getSavedObjectsRepository` | [`core.savedObjects.createInternalRepository`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md) [`core.savedObjects.createScopedRepository`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.createscopedrepository.md) | | -| `server.savedObjects.getScopedSavedObjectsClient` | [`core.savedObjects.getScopedClient`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.getscopedclient.md) | | -| `request.getSavedObjectsClient` | [`context.core.savedObjects.client`](/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md) | | -| `request.getUiSettingsService` | [`context.core.uiSettings.client`](/docs/development/core/server/kibana-plugin-core-server.iuisettingsclient.md) | | -| `kibana.Plugin.deprecations` | [Handle plugin configuration deprecations](#handle-plugin-config-deprecations) and [`PluginConfigDescriptor.deprecations`](docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.md) | Deprecations from New Platform are not applied to legacy configuration | -| `kibana.Plugin.savedObjectSchemas` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | -| `kibana.Plugin.mappings` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | -| `kibana.Plugin.migrations` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | -| `kibana.Plugin.savedObjectsManagement` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | - -_See also: [Server's CoreSetup API Docs](/docs/development/core/server/kibana-plugin-core-server.coresetup.md)_ - -##### Plugin services - -| Legacy Platform | New Platform | Notes | -| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- | -| `server.plugins.xpack_main.registerFeature` | [`plugins.features.registerKibanaFeature`](x-pack/plugins/features/server/plugin.ts) | | -| `server.plugins.xpack_main.feature(pluginID).registerLicenseCheckResultsGenerator` | [`x-pack licensing plugin`](/x-pack/plugins/licensing/README.md) | | - -#### UI Exports - -The legacy platform uses a set of "uiExports" to inject modules from one plugin into other plugins. This mechansim is not necessary in the New Platform because all plugins are executed on the page at once (though only one application) is rendered at a time. - -This table shows where these uiExports have moved to in the New Platform. In most cases, if a uiExport you need is not yet available in the New Platform, you may leave in your legacy plugin for the time being and continue to migrate the rest of your app to the New Platform. - -| Legacy Platform | New Platform | Notes | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `aliases` | | | -| `app` | [`core.application.register`](/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md) | | -| `canvas` | | Should be an API on the canvas plugin. | -| `chromeNavControls` | [`core.chrome.navControls.register{Left,Right}`](/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.md) | | -| `contextMenuActions` | | Should be an API on the devTools plugin. | -| `devTools` | | | -| `docViews` | [`plugins.discover.docViews.addDocView`](./src/plugins/discover/public/doc_views) | Should be an API on the discover plugin. | -| `embeddableActions` | | Should be an API on the embeddables plugin. | -| `embeddableFactories` | | Should be an API on the embeddables plugin. | -| `fieldFormatEditors` | | | -| `fieldFormats` | [`plugins.data.fieldFormats`](./src/plugins/data/public/field_formats) | | -| `hacks` | n/a | Just run the code in your plugin's `start` method. | -| `home` | [`plugins.home.featureCatalogue.register`](./src/plugins/home/public/feature_catalogue) | Must add `home` as a dependency in your kibana.json. | -| `indexManagement` | | Should be an API on the indexManagement plugin. | -| `injectDefaultVars` | n/a | Plugins will only be able to allow config values for the frontend. See [#41990](https://github.com/elastic/kibana/issues/41990) | -| `inspectorViews` | | Should be an API on the data (?) plugin. | -| `interpreter` | | Should be an API on the interpreter plugin. | -| `links` | n/a | Not necessary, just register your app via `core.application.register` | -| `managementSections` | [`plugins.management.sections.register`](/rfcs/text/0006_management_section_service.md) | | -| `mappings` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `migrations` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `navbarExtensions` | n/a | Deprecated | -| `savedObjectSchemas` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `savedObjectsManagement` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `savedObjectTypes` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `search` | | | -| `shareContextMenuExtensions` | | | -| `taskDefinitions` | | Should be an API on the taskManager plugin. | -| `uiCapabilities` | [`core.application.register`](/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md) | | -| `uiSettingDefaults` | [`core.uiSettings.register`](/docs/development/core/server/kibana-plugin-core-server.uisettingsservicesetup.md) | | -| `validations` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `visEditorTypes` | | | -| `visTypeEnhancers` | | | -| `visTypes` | `plugins.visualizations.types` | | -| `visualize` | | | - -#### Plugin Spec - -| Legacy Platform | New Platform | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `id` | [`manifest.id`](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) | -| `require` | [`manifest.requiredPlugins`](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) | -| `version` | [`manifest.version`](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) | -| `kibanaVersion` | [`manifest.kibanaVersion`](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) | -| `configPrefix` | [`manifest.configPath`](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) | -| `config` | [export config](#configure-plugin) | -| `deprecations` | [export config](#handle-plugin-configuration-deprecations) | -| `uiExports` | `N/A`. Use platform & plugin public contracts | -| `publicDir` | `N/A`. Platform serves static assets from `/public/assets` folder under `/plugins/{id}/assets/{path*}` URL. | -| `preInit`, `init`, `postInit` | `N/A`. Use NP [lifecycle events](#services) | - -## How to - -### Configure plugin - -Kibana provides ConfigService if a plugin developer may want to support adjustable runtime behavior for their plugins. Access to Kibana config in New platform has been subject to significant refactoring. - -Config service does not provide access to the whole config anymore. New platform plugin cannot read configuration parameters of the core services nor other plugins directly. Use plugin contract to provide data. - -```js -// your-plugin.js -// in Legacy platform -const basePath = config.get('server.basePath'); -// in New platform -const basePath = core.http.basePath.get(request); -``` - -In order to have access to your plugin config, you *should*: - -- Declare plugin specific "configPath" (will fallback to plugin "id" if not specified) in `kibana.json` file. -- Export schema validation for config from plugin's main file. Schema is mandatory. If a plugin reads from the config without schema declaration, ConfigService will throw an error. - -```typescript -// my_plugin/server/index.ts -import { schema, TypeOf } from '@kbn/config-schema'; -export const plugin = ... -export const config = { - schema: schema.object(...), -}; -export type MyPluginConfigType = TypeOf; -``` - -- Read config value exposed via initializerContext. No config path is required. - -```typescript -class MyPlugin { - constructor(initializerContext: PluginInitializerContext) { - this.config$ = initializerContext.config.create(); - // or if config is optional: - this.config$ = initializerContext.config.createIfExists(); - } -``` - -If your plugin also have a client-side part, you can also expose configuration properties to it using the configuration `exposeToBrowser` allow-list property. - -```typescript -// my_plugin/server/index.ts -import { schema, TypeOf } from '@kbn/config-schema'; -import { PluginConfigDescriptor } from 'kibana/server'; - -const configSchema = schema.object({ - secret: schema.string({ defaultValue: 'Only on server' }), - uiProp: schema.string({ defaultValue: 'Accessible from client' }), -}); - -type ConfigType = TypeOf; - -export const config: PluginConfigDescriptor = { - exposeToBrowser: { - uiProp: true, - }, - schema: configSchema, -}; -``` - -Configuration containing only the exposed properties will be then available on the client-side using the plugin's `initializerContext`: - -```typescript -// my_plugin/public/index.ts -interface ClientConfigType { - uiProp: string; -} - -export class Plugin implements Plugin { - constructor(private readonly initializerContext: PluginInitializerContext) {} - - public async setup(core: CoreSetup, deps: {}) { - const config = this.initializerContext.config.get(); - // ... - } -``` - -All plugins are considered enabled by default. If you want to disable your plugin by default, you could declare the `enabled` flag in plugin config. This is a special Kibana platform key. The platform reads its value and won't create a plugin instance if `enabled: false`. - -```js -export const config = { - schema: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), -}; -``` - -#### Handle plugin configuration deprecations - -If your plugin have deprecated properties, you can describe them using the `deprecations` config descriptor field. - -The system is quite similar to the legacy plugin's deprecation management. The most important difference -is that deprecations are managed on a per-plugin basis, meaning that you don't need to specify the whole -property path, but use the relative path from your plugin's configuration root. - -```typescript -// my_plugin/server/index.ts -import { schema, TypeOf } from '@kbn/config-schema'; -import { PluginConfigDescriptor } from 'kibana/server'; - -const configSchema = schema.object({ - newProperty: schema.string({ defaultValue: 'Some string' }), -}); - -type ConfigType = TypeOf; - -export const config: PluginConfigDescriptor = { - schema: configSchema, - deprecations: ({ rename, unused }) => [ - rename('oldProperty', 'newProperty'), - unused('someUnusedProperty'), - ], -}; -``` - -In some cases, accessing the whole configuration for deprecations is necessary. For these edge cases, -`renameFromRoot` and `unusedFromRoot` are also accessible when declaring deprecations. - -```typescript -// my_plugin/server/index.ts -export const config: PluginConfigDescriptor = { - schema: configSchema, - deprecations: ({ renameFromRoot, unusedFromRoot }) => [ - renameFromRoot('oldplugin.property', 'myplugin.property'), - unusedFromRoot('oldplugin.deprecated'), - ], -}; -``` - -Note that deprecations registered in new platform's plugins are not applied to the legacy configuration. -During migration, if you still need the deprecations to be effective in the legacy plugin, you need to declare them in -both plugin definitions. - -### Use scoped services - -Whenever Kibana needs to get access to data saved in elasticsearch, it should perform a check whether an end-user has access to the data. -In the legacy platform, Kibana requires to bind elasticsearch related API with an incoming request to access elasticsearch service on behalf of a user. - -```js -async function handler(req, res) { - const dataCluster = server.plugins.elasticsearch.getCluster('data'); - const data = await dataCluster.callWithRequest(req, 'ping'); -} -``` - -The new platform introduced [a handler interface](/rfcs/text/0003_handler_interface.md) on the server-side to perform that association internally. Core services, that require impersonation with an incoming request, are -exposed via `context` argument of [the request handler interface.](/docs/development/core/server/kibana-plugin-core-server.requesthandler.md) -The above example looks in the new platform as - -```js -async function handler(context, req, res) { - const data = await context.core.elasticsearch.adminClient.callAsInternalUser('ping'); -} -``` - -The [request handler context](/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md) exposed the next scoped **core** services: - -| Legacy Platform | New Platform | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `request.getSavedObjectsClient` | [`context.savedObjects.client`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md) | -| `server.plugins.elasticsearch.getCluster('admin')` | [`context.elasticsearch.adminClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | -| `server.plugins.elasticsearch.getCluster('data')` | [`context.elasticsearch.dataClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | -| `request.getUiSettingsService` | [`context.uiSettings.client`](/docs/development/core/server/kibana-plugin-core-server.iuisettingsclient.md) | - -#### Declare a custom scoped service - -Plugins can extend the handler context with custom API that will be available to the plugin itself and all dependent plugins. -For example, the plugin creates a custom elasticsearch client and want to use it via the request handler context: - -```ts -import { CoreSetup, IScopedClusterClient } from 'kibana/server'; - -export interface MyPluginContext { - client: IScopedClusterClient; -} - -// extend RequestHandlerContext when a dependent plugin imports MyPluginContext from the file -declare module 'src/core/server' { - interface RequestHandlerContext { - myPlugin?: MyPluginContext; - } -} - -class Plugin { - setup(core: CoreSetup) { - const client = core.elasticsearch.createClient('myClient'); - core.http.registerRouteHandlerContext('myPlugin', (context, req, res) => { - return { client: client.asScoped(req) }; - }); - - router.get( - { path: '/api/my-plugin/', validate }, - async (context, req, res) => { - const data = await context.myPlugin.client.callAsCurrentUser('endpoint'); - ... - } - ); - } -``` - -### Mock new platform services in tests - -#### Writing mocks for your plugin - -Core services already provide mocks to simplify testing and make sure plugins always rely on valid public contracts: - -```typescript -// my_plugin/server/plugin.test.ts -import { configServiceMock } from 'src/core/server/mocks'; - -const configService = configServiceMock.create(); -configService.atPath.mockReturnValue(config$); -… -const plugin = new MyPlugin({ configService }, …); -``` - -Or if you need to get the whole core `setup` or `start` contracts: - -```typescript -// my_plugin/public/plugin.test.ts -import { coreMock } from 'src/core/public/mocks'; - -const coreSetup = coreMock.createSetup(); -coreSetup.uiSettings.get.mockImplementation((key: string) => { - … -}); -… -const plugin = new MyPlugin(coreSetup, ...); -``` - -Although it isn't mandatory, we strongly recommended you export your plugin mocks as well, in order for dependent plugins to use them in tests. Your plugin mocks should be exported from the root `/server` and `/public` directories in your plugin: - -```typescript -// my_plugin/server/mocks.ts or my_plugin/public/mocks.ts -const createSetupContractMock = () => { - const startContract: jest.Mocked= { - isValid: jest.fn(); - } - // here we already type check as TS infers to the correct type declared above - startContract.isValid.mockReturnValue(true); - return startContract; -} - -export const myPluginMocks = { - createSetup: createSetupContractMock, - createStart: … -} -``` - -Plugin mocks should consist of mocks for *public APIs only*: setup/start/stop contracts. Mocks aren't necessary for pure functions as other plugins can call the original implementation in tests. - -#### Using mocks in your tests - -During the migration process, it is likely you are preparing your plugin by shimming in new platform-ready dependencies via the legacy `ui/new_platform` module: - -```typescript -import { npSetup, npStart } from 'ui/new_platform'; -``` - -If you are using this approach, the easiest way to mock core and new platform-ready plugins in your legacy tests is to mock the `ui/new_platform` module: - -```typescript -jest.mock('ui/new_platform'); -``` - -This will automatically mock the services in `ui/new_platform` thanks to the [helpers that have been added](../../src/legacy/ui/public/new_platform/__mocks__/helpers.ts) to that module. - -If others are consuming your plugin's new platform contracts via the `ui/new_platform` module, you'll want to update the helpers as well to ensure your contracts are properly mocked. - -> Note: The `ui/new_platform` mock is only designed for use by old Jest tests. If you are writing new tests, you should structure your code and tests such that you don't need this mock. Instead, you should import the `core` mock directly and instantiate it. - -### Provide Legacy Platform API to the New platform plugin - -#### On the server side - -During migration, you can face a problem that not all API is available in the New platform yet. You can work around this by extending your -new platform plugin with Legacy API: - -- create New platform plugin -- New platform plugin should expose a method `registerLegacyAPI` that allows passing API from the Legacy platform and store it in the NP plugin instance - -```js -class MyPlugin { - public async setup(core){ - return { - registerLegacyAPI: (legacyAPI) => (this.legacyAPI = legacyAPI) - } - } -} -``` - -- The legacy plugin provides API calling `registerLegacyAPI` - -```js -new kibana.Plugin({ - init(server){ - const myPlugin = server.newPlatform.setup.plugins.myPlugin; - if (!myPlugin) { - throw new Error('myPlugin plugin is not available.'); - } - myPlugin.registerLegacyAPI({ ... }); - } -}) -``` - -- The new platform plugin access stored Legacy platform API via `getLegacyAPI` getter. Getter function must have name indicating that’s API provided from the Legacy platform. - -```js -class MyPlugin { - private getLegacyAPI(){ - return this.legacyAPI; - } - public async setup(core){ - const routeHandler = (context, req, req) => { - const legacyApi = this.getLegacyAPI(); - // ... - } - return { - registerLegacyAPI: (legacyAPI) => (this.legacyAPI = legacyAPI) - } - } -} -``` - -#### On the client side - -It's not currently possible to use a similar pattern on the client-side. -Because Legacy platform plugins heavily rely on global angular modules, which aren't available on the new platform. -So you can utilize the same approach for only *stateless Angular components*, as long as they are not consumed by a New Platform application. When New Platform applications are on the page, no legacy code is executed, so the `registerLegacyAPI` function would not be called. - -### Updates an application navlink at runtime - -The application API now provides a way to updates some of a registered application's properties after registration. - -```typescript -// inside your plugin's setup function -export class MyPlugin implements Plugin { - private appUpdater = new BehaviorSubject(() => ({})); - setup({ application }) { - application.register({ - id: 'my-app', - title: 'My App', - updater$: this.appUpdater, - async mount(params) { - const { renderApp } = await import('./application'); - return renderApp(params); - }, - }); - } - start() { - // later, when the navlink needs to be updated - appUpdater.next(() => { - navLinkStatus: AppNavLinkStatus.disabled, - tooltip: 'Application disabled', - }) - } -``` - -### Logging config migration - -[Read](./server/logging/README.md#logging-config-migration) - -### Use HashRouter in migrated apps - -Kibana applications are meant to be leveraging the `ScopedHistory` provided in an app's `mount` function to wire their router. For react, -this is done by using the `react-router-dom` `Router` component: - -```typescript -export const renderApp = async (element: HTMLElement, history: ScopedHistory) => { - render( - - - - - - - - , - element - ); - - return () => { - unmountComponentAtNode(element); - unlisten(); - }; -}; -``` - -Some legacy apps were using `react-router-dom`'s `HashRouter` instead. Using `HashRouter` in a migrated application will cause some route change -events to not be catched by the router, as the `BrowserHistory` used behind the provided scoped history does not emit -the `hashevent` that is required for the `HashRouter` to behave correctly. - -It is strictly recommended to migrate your application's routing to browser history, which is the only routing officially supported by the platform. - -However, during the transition period, it is possible to make the two histories cohabitate by manually emitting the required events from -the scoped to the hash history. You may use this workaround at your own risk. While we are not aware of any problems it currently creates, there may be edge cases that do not work properly. - -```typescript -export const renderApp = async (element: HTMLElement, history: ScopedHistory) => { - render( - - - - - - - - , - element - ); - - // dispatch synthetic hash change event to update hash history objects - // this is necessary because hash updates triggered by the scoped history will not emit them. - const unlisten = history.listen(() => { - window.dispatchEvent(new HashChangeEvent('hashchange')); - }); - - return () => { - unmountComponentAtNode(element); - // unsubscribe to `history.listen` when unmounting. - unlisten(); - }; -}; -``` diff --git a/src/core/MIGRATION_EXAMPLES.md b/src/core/MIGRATION_EXAMPLES.md deleted file mode 100644 index 3f34742e44861c..00000000000000 --- a/src/core/MIGRATION_EXAMPLES.md +++ /dev/null @@ -1,1291 +0,0 @@ -# Migration Examples - -This document is a list of examples of how to migrate plugin code from legacy -APIs to their New Platform equivalents. - -- [Migration Examples](#migration-examples) - - [Configuration](#configuration) - - [Declaring config schema](#declaring-config-schema) - - [Using New Platform config in a new plugin](#using-new-platform-config-in-a-new-plugin) - - [Using New Platform config from a Legacy plugin](#using-new-platform-config-from-a-legacy-plugin) - - [Create a New Platform plugin](#create-a-new-platform-plugin) - - [HTTP Routes](#http-routes) - - [1. Legacy route registration](#1-legacy-route-registration) - - [2. New Platform shim using legacy router](#2-new-platform-shim-using-legacy-router) - - [3. New Platform shim using New Platform router](#3-new-platform-shim-using-new-platform-router) - - [4. New Platform plugin](#4-new-platform-plugin) - - [Accessing Services](#accessing-services) - - [Migrating Hapi "pre" handlers](#migrating-hapi-pre-handlers) - - [Simple example](#simple-example) - - [Full Example](#full-example) - - [Chrome](#chrome) - - [Updating an application navlink](#updating-an-application-navlink) - - [Chromeless Applications](#chromeless-applications) - - [Render HTML Content](#render-html-content) - - [Saved Objects types](#saved-objects-types) - - [Concrete example](#concrete-example) - - [Changes in structure compared to legacy](#changes-in-structure-compared-to-legacy) - - [Remarks](#remarks) - - [UiSettings](#uisettings) - - [Elasticsearch client](#elasticsearch-client) - - [Client API Changes](#client-api-changes) - - [Accessing the client from a route handler](#accessing-the-client-from-a-route-handler) - - [Creating a custom client](#creating-a-custom-client) - -## Configuration - -### Declaring config schema - -Declaring the schema of your configuration fields is similar to the Legacy Platform but uses the `@kbn/config-schema` package instead of Joi. This package has full TypeScript support, but may be missing some features you need. Let the Platform team know by opening an issue and we'll add what you're missing. - -```ts -// Legacy config schema -import Joi from 'joi'; - -new kibana.Plugin({ - config() { - return Joi.object({ - enabled: Joi.boolean().default(true), - defaultAppId: Joi.string().default('home'), - index: Joi.string().default('.kibana'), - disableWelcomeScreen: Joi.boolean().default(false), - autocompleteTerminateAfter: Joi.number().integer().min(1).default(100000), - }) - } -}); - -// New Platform equivalent -import { schema, TypeOf } from '@kbn/config-schema'; - -export const config = { - schema: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - defaultAppId: schema.string({ defaultValue: true }), - index: schema.string({ defaultValue: '.kibana' }), - disableWelcomeScreen: schema.boolean({ defaultValue: false }), - autocompleteTerminateAfter: schema.duration({ min: 1, defaultValue: 100000 }), - }) -}; - -// @kbn/config-schema is written in TypeScript, so you can use your schema -// definition to create a type to use in your plugin code. -export type MyPluginConfig = TypeOf; -``` - -### Using New Platform config in a new plugin - -After setting the config schema for your plugin, you might want to reach the configuration in the plugin. -It is provided as part of the [PluginInitializerContext](../../docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md) -in the *constructor* of the plugin: - -```ts -// myPlugin/(public|server)/index.ts - -import { PluginInitializerContext } from 'kibana/server'; -import { MyPlugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new MyPlugin(initializerContext); -} -``` - -```ts -// myPlugin/(public|server)/plugin.ts - -import { Observable } from 'rxjs'; -import { first } from 'rxjs/operators'; -import { CoreSetup, Logger, Plugin, PluginInitializerContext, PluginName } from 'kibana/server'; -import { MyPlugin } from './plugin'; - -export class MyPlugin implements Plugin { - private readonly config$: Observable; - private readonly log: Logger; - - constructor(private readonly initializerContext: PluginInitializerContext) { - this.log = initializerContext.logger.get(); - this.config$ = initializerContext.config.create(); - } - - public async setup(core: CoreSetup, deps: Record) { - const isEnabled = await this.config$.pipe(first()).toPromise(); - ... - } - ... -} -} -``` - -Additionally, some plugins need to read other plugins' config to act accordingly (like timing out a request, matching ElasticSearch's timeout). For those use cases, the plugin can rely on the *globalConfig* and *env* properties in the context: - -```ts -export class MyPlugin implements Plugin { -... - public async setup(core: CoreSetup, deps: Record) { - const { mode: { dev }, packageInfo: { version } } = this.initializerContext.env; - const { elasticsearch: { shardTimeout }, path: { data } } = await this.initializerContext.config.legacy.globalConfig$ - .pipe(first()).toPromise(); - ... - } -``` - -### Using New Platform config from a Legacy plugin - -During the migration process, you'll want to migrate your schema to the new -format. However, legacy plugins cannot directly get access to New Platform's -config service due to the way that config is tied to the `kibana.json` file -(which does not exist for legacy plugins). - -There is a workaround though: - -- Create a New Platform plugin that contains your plugin's config schema in the new format -- Expose the config from the New Platform plugin in its setup contract -- Read the config from the setup contract in your legacy plugin - -#### Create a New Platform plugin - -For example, if wanted to move the legacy `timelion` plugin's configuration to -the New Platform, we could create a NP plugin with the same name in -`src/plugins/timelion` with the following files: - -```json5 -// src/plugins/timelion/kibana.json -{ - "id": "timelion", - "server": true -} -``` - -```ts -// src/plugins/timelion/server/index.ts -import { schema, TypeOf } from '@kbn/config-schema'; -import { PluginInitializerContext } from 'src/core/server'; -import { TimelionPlugin } from './plugin'; - -export const config = { - schema: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - }); -} - -export const plugin = (initContext: PluginInitializerContext) => new TimelionPlugin(initContext); - -export type TimelionConfig = TypeOf; -export { TimelionSetup } from './plugin'; -``` - -```ts -// src/plugins/timelion/server/plugin.ts -import { PluginInitializerContext, Plugin, CoreSetup } from '../../core/server'; -import { TimelionConfig } from '.'; - -export class TimelionPlugin implements Plugin { - constructor(private readonly initContext: PluginInitializerContext) {} - - public setup(core: CoreSetup) { - return { - __legacy: { - config$: this.initContext.config.create(), - }, - }; - } - - public start() {} - public stop() {} -} - -export interface TimelionSetup { - /** @deprecated */ - __legacy: { - config$: Observable; - }; -} -``` - -With the New Platform plugin in place, you can then read this `config$` -Observable from your legacy plugin: - -```ts -import { take } from 'rxjs/operators'; - -new kibana.Plugin({ - async init(server) { - const { config$ } = server.newPlatform.setup.plugins.timelion; - const currentConfig = await config$.pipe(take(1)).toPromise(); - } -}); -``` - -## HTTP Routes - -In the legacy platform, plugins have direct access to the Hapi `server` object -which gives full access to all of Hapi's API. In the New Platform, plugins have -access to the -[HttpServiceSetup](/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md) -interface, which is exposed via the -[CoreSetup](/docs/development/core/server/kibana-plugin-core-server.coresetup.md) -object injected into the `setup` method of server-side plugins. - -This interface has a different API with slightly different behaviors. - -- All input (body, query parameters, and URL parameters) must be validated using - the `@kbn/config-schema` package. If no validation schema is provided, these - values will be empty objects. -- All exceptions thrown by handlers result in 500 errors. If you need a specific - HTTP error code, catch any exceptions in your handler and construct the - appropriate response using the provided response factory. While you can - continue using the `boom` module internally in your plugin, the framework does - not have native support for converting Boom exceptions into HTTP responses. - -Because of the incompatibility between the legacy and New Platform HTTP Route -API's it might be helpful to break up your migration work into several stages. - -### 1. Legacy route registration - -```ts -// legacy/plugins/myplugin/index.ts -import Joi from 'joi'; - -new kibana.Plugin({ - init(server) { - server.route({ - path: '/api/demoplugin/search', - method: 'POST', - options: { - validate: { - payload: Joi.object({ - field1: Joi.string().required(), - }), - } - }, - handler(req, h) { - return { message: `Received field1: ${req.payload.field1}` }; - } - }); - } -}); -``` - -### 2. New Platform shim using legacy router - -Create a New Platform shim and inject the legacy `server.route` into your -plugin's setup function. - -```ts -// legacy/plugins/demoplugin/index.ts -import { Plugin, LegacySetup } from './server/plugin'; -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - // core shim - const coreSetup: server.newPlatform.setup.core; - const pluginSetup = {}; - const legacySetup: LegacySetup = { - route: server.route - }; - - new Plugin().setup(coreSetup, pluginSetup, legacySetup); - } - } -} -``` - -```ts -// legacy/plugins/demoplugin/server/plugin.ts -import { CoreSetup } from 'src/core/server'; -import { Legacy } from 'kibana'; - -export interface LegacySetup { - route: Legacy.Server['route']; -}; - -export interface DemoPluginsSetup {}; - -export class Plugin { - public setup(core: CoreSetup, plugins: DemoPluginsSetup, __LEGACY: LegacySetup) { - __LEGACY.route({ - path: '/api/demoplugin/search', - method: 'POST', - options: { - validate: { - payload: Joi.object({ - field1: Joi.string().required(), - }), - } - }, - async handler(req) { - return { message: `Received field1: ${req.payload.field1}` }; - }, - }); - } -} -``` - -### 3. New Platform shim using New Platform router - -We now switch the shim to use the real New Platform HTTP API's in `coreSetup` -instead of relying on the legacy `server.route`. Since our plugin is now using -the New Platform API's we are guaranteed that our HTTP route handling is 100% -compatible with the New Platform. As a result, we will also have to adapt our -route registration accordingly. - -```ts -// legacy/plugins/demoplugin/index.ts -import { Plugin } from './server/plugin'; -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - // core shim - const coreSetup = server.newPlatform.setup.core; - const pluginSetup = {}; - - new Plugin().setup(coreSetup, pluginSetup); - } - } -} -``` - -```ts -// legacy/plugins/demoplugin/server/plugin.ts -import { schema } from '@kbn/config-schema'; -import { CoreSetup } from 'src/core/server'; - -export interface DemoPluginsSetup {}; - -class Plugin { - public setup(core: CoreSetup, pluginSetup: DemoPluginSetup) { - const router = core.http.createRouter(); - router.post( - { - path: '/api/demoplugin/search', - validate: { - body: schema.object({ - field1: schema.string(), - }), - } - }, - (context, req, res) => { - return res.ok({ - body: { - message: `Received field1: ${req.body.field1}` - } - }); - } - ) - } -} -``` - -If your plugin still relies on throwing Boom errors from routes, you can use the `router.handleLegacyErrors` -as a temporary solution until error migration is complete: - -```ts -// legacy/plugins/demoplugin/server/plugin.ts -import { schema } from '@kbn/config-schema'; -import { CoreSetup } from 'src/core/server'; - -export interface DemoPluginsSetup {}; - -class Plugin { - public setup(core: CoreSetup, pluginSetup: DemoPluginSetup) { - const router = core.http.createRouter(); - router.post( - { - path: '/api/demoplugin/search', - validate: { - body: schema.object({ - field1: schema.string(), - }), - } - }, - router.handleLegacyErrors((context, req, res) => { - throw Boom.notFound('not there'); // will be converted into proper New Platform error - }) - ) - } -} -``` - -#### 4. New Platform plugin - -As the final step we delete the shim and move all our code into a New Platform -plugin. Since we were already consuming the New Platform API's no code changes -are necessary inside `plugin.ts`. - -```ts -// Move legacy/plugins/demoplugin/server/plugin.ts -> plugins/demoplugin/server/plugin.ts -``` - -### Accessing Services - -Services in the Legacy Platform were typically available via methods on either -`server.plugins.*`, `server.*`, or `req.*`. In the New Platform, all services -are available via the `context` argument to the route handler. The type of this -argument is the -[RequestHandlerContext](/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md). -The APIs available here will include all Core services and any services -registered by plugins this plugin depends on. - -```ts -new kibana.Plugin({ - init(server) { - const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); - - server.route({ - path: '/api/my-plugin/my-route', - method: 'POST', - async handler(req, h) { - const results = await callWithRequest(req, 'search', query); - return { results }; - } - }); - } -}); - -class Plugin { - public setup(core) { - const router = core.http.createRouter(); - router.post( - { - path: '/api/my-plugin/my-route', - }, - async (context, req, res) => { - const results = await context.elasticsearch.dataClient.callAsCurrentUser('search', query); - return res.ok({ - body: { results } - }); - } - ) - } -} -``` - -### Migrating Hapi "pre" handlers - -In the Legacy Platform, routes could provide a "pre" option in their config to -register a function that should be run prior to the route handler. These -"pre" handlers allow routes to share some business logic that may do some -pre-work or validation. In Kibana, these are often used for license checks. - -The Kibana Platform's HTTP interface does not provide this functionality, -however it is simple enough to port over using a higher-order function that can -wrap the route handler. - -#### Simple example - -In this simple example, a pre-handler is used to either abort the request with -an error or continue as normal. This is a simple "gate-keeping" pattern. - -```ts -// Legacy pre-handler -const licensePreRouting = (request) => { - const licenseInfo = getMyPluginLicenseInfo(request.server.plugins.xpack_main); - if (!licenseInfo.isOneOf(['gold', 'platinum', 'trial'])) { - throw Boom.forbidden(`You don't have the right license for MyPlugin!`); - } -} - -server.route({ - method: 'GET', - path: '/api/my-plugin/do-something', - config: { - pre: [{ method: licensePreRouting }] - }, - handler: (req) => { - return doSomethingInteresting(); - } -}) -``` - -In the Kibana Platform, the same functionality can be acheived by creating a -function that takes a route handler (or factory for a route handler) as an -argument and either invokes it in the successful case or returns an error -response in the failure case. - -We'll call this a "high-order handler" similar to the "high-order component" -pattern common in the React ecosystem. - -```ts -// New Platform high-order handler -const checkLicense = ( - handler: RequestHandler -): RequestHandler => { - return (context, req, res) => { - const licenseInfo = getMyPluginLicenseInfo(context.licensing.license); - - if (licenseInfo.hasAtLeast('gold')) { - return handler(context, req, res); - } else { - return res.forbidden({ body: `You don't have the right license for MyPlugin!` }); - } - } -} - -router.get( - { path: '/api/my-plugin/do-something', validate: false }, - checkLicense(async (context, req, res) => { - const results = doSomethingInteresting(); - return res.ok({ body: results }); - }), -) -``` - -#### Full Example - -In some cases, the route handler may need access to data that the pre-handler -retrieves. In this case, you can utilize a handler _factory_ rather than a raw -handler. - -```ts -// Legacy pre-handler -const licensePreRouting = (request) => { - const licenseInfo = getMyPluginLicenseInfo(request.server.plugins.xpack_main); - if (licenseInfo.isOneOf(['gold', 'platinum', 'trial'])) { - // In this case, the return value of the pre-handler is made available on - // whatever the 'assign' option is in the route config. - return licenseInfo; - } else { - // In this case, the route handler is never called and the user gets this - // error message - throw Boom.forbidden(`You don't have the right license for MyPlugin!`); - } -} - -server.route({ - method: 'GET', - path: '/api/my-plugin/do-something', - config: { - pre: [{ method: licensePreRouting, assign: 'licenseInfo' }] - }, - handler: (req) => { - const licenseInfo = req.pre.licenseInfo; - return doSomethingInteresting(licenseInfo); - } -}) -``` - -In many cases, it may be simpler to duplicate the function call -to retrieve the data again in the main handler. In this other cases, you can -utilize a handler _factory_ rather than a raw handler as the argument to your -high-order handler. This way the high-order handler can pass arbitrary arguments -to the route handler. - -```ts -// New Platform high-order handler -const checkLicense = ( - handlerFactory: (licenseInfo: MyPluginLicenseInfo) => RequestHandler -): RequestHandler => { - return (context, req, res) => { - const licenseInfo = getMyPluginLicenseInfo(context.licensing.license); - - if (licenseInfo.hasAtLeast('gold')) { - const handler = handlerFactory(licenseInfo); - return handler(context, req, res); - } else { - return res.forbidden({ body: `You don't have the right license for MyPlugin!` }); - } - } -} - -router.get( - { path: '/api/my-plugin/do-something', validate: false }, - checkLicense(licenseInfo => async (context, req, res) => { - const results = doSomethingInteresting(licenseInfo); - return res.ok({ body: results }); - }), -) -``` - -## Chrome - -In the Legacy Platform, the `ui/chrome` import contained APIs for a very wide -range of features. In the New Platform, some of these APIs have changed or moved -elsewhere. - -| Legacy Platform | New Platform | Notes | -|-------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `chrome.addBasePath` | [`core.http.basePath.prepend`](/docs/development/core/public/kibana-plugin-public.httpservicebase.basepath.md) | | -| `chrome.breadcrumbs.set` | [`core.chrome.setBreadcrumbs`](/docs/development/core/public/kibana-plugin-public.chromestart.setbreadcrumbs.md) | | -| `chrome.getUiSettingsClient` | [`core.uiSettings`](/docs/development/core/public/kibana-plugin-public.uisettingsclient.md) | | -| `chrome.helpExtension.set` | [`core.chrome.setHelpExtension`](/docs/development/core/public/kibana-plugin-public.chromestart.sethelpextension.md) | | -| `chrome.setVisible` | [`core.chrome.setIsVisible`](/docs/development/core/public/kibana-plugin-public.chromestart.setisvisible.md) | | -| `chrome.getInjected` | [`core.injectedMetadata.getInjected`](/docs/development/core/public/kibana-plugin-public.coresetup.injectedmetadata.md) (temporary) | A temporary API is available to read injected vars provided by legacy plugins. This will be removed after [#41990](https://github.com/elastic/kibana/issues/41990) is completed. | -| `chrome.setRootTemplate` / `chrome.setRootController` | -- | Use application mounting via `core.application.register` (not currently avaiable to legacy plugins). | -| `chrome.navLinks.update` | [`core.appbase.updater`](/docs/development/core/public/kibana-plugin-public.appbase.updater_.md) | Use the `updater$` property when registering your application via `core.application.register` | - -In most cases, the most convenient way to access these APIs will be via the -[AppMountContext](/docs/development/core/public/kibana-plugin-public.appmountcontext.md) -object passed to your application when your app is mounted on the page. - -### Updating an application navlink - -In the legacy platform, the navlink could be updated using `chrome.navLinks.update` - -```ts -uiModules.get('xpack/ml').run(() => { - const showAppLink = xpackInfo.get('features.ml.showLinks', false); - const isAvailable = xpackInfo.get('features.ml.isAvailable', false); - - const navLinkUpdates = { - // hide by default, only show once the xpackInfo is initialized - hidden: !showAppLink, - disabled: !showAppLink || (showAppLink && !isAvailable), - }; - - npStart.core.chrome.navLinks.update('ml', navLinkUpdates); -}); -``` - -In the new platform, navlinks should not be updated directly. Instead, it is now possible to add an `updater` when -registering an application to change the application or the navlink state at runtime. - -```ts -// my_plugin has a required dependencie to the `licensing` plugin -interface MyPluginSetupDeps { - licensing: LicensingPluginSetup; -} - -export class MyPlugin implements Plugin { - setup({ application }, { licensing }: MyPluginSetupDeps) { - const updater$ = licensing.license$.pipe( - map(license => { - const { hidden, disabled } = calcStatusFor(license); - if (hidden) return { navLinkStatus: AppNavLinkStatus.hidden }; - if (disabled) return { navLinkStatus: AppNavLinkStatus.disabled }; - return { navLinkStatus: AppNavLinkStatus.default }; - }) - ); - - application.register({ - id: 'my-app', - title: 'My App', - updater$, - async mount(params) { - const { renderApp } = await import('./application'); - return renderApp(params); - }, - }); - } -``` - -## Chromeless Applications - -In Kibana, a "chromeless" application is one where the primary Kibana UI components -such as header or navigation can be hidden. In the legacy platform these were referred to -as "hidden" applications, and were set via the `hidden` property in a Kibana plugin. -Chromeless applications are also not displayed in the left navbar. - -To mark an application as chromeless, specify `chromeless: false` when registering your application -to hide the chrome UI when the application is mounted: - -```ts -application.register({ - id: 'chromeless', - chromeless: true, - async mount(context, params) { - /* ... */ - }, -}); -``` - -If you wish to render your application at a route that does not follow the `/app/${appId}` pattern, -this can be done via the `appRoute` property. Doing this currently requires you to register a server -route where you can return a bootstrapped HTML page for your application bundle. Instructions on -registering this server route is covered in the next section: [Render HTML Content](#render-html-content). - -```ts -application.register({ - id: 'chromeless', - appRoute: '/chromeless', - chromeless: true, - async mount(context, params) { - /* ... */ - }, -}); -``` - -## Render HTML Content - -You can return a blank HTML page bootstrapped with the core application bundle from an HTTP route handler -via the `httpResources` service. You may wish to do this if you are rendering a chromeless application with a -custom application route or have other custom rendering needs. - -```typescript -httpResources.register( - { path: '/chromeless', validate: false }, - (context, request, response) => { - //... some logic - return response.renderCoreApp(); - } -); -``` - -You can also specify to exclude user data from the bundle metadata. User data -comprises all UI Settings that are *user provided*, then injected into the page. -You may wish to exclude fetching this data if not authorized or to slim the page -size. - -```typescript -httpResources.register( - { path: '/', validate: false, options: { authRequired: false } }, - (context, request, response) => { - //... some logic - return response.renderAnonymousCoreApp(); - } -); -``` - -## Saved Objects types - -In the legacy platform, saved object types were registered using static definitions in the `uiExports` part of -the plugin manifest. - -In the new platform, all these registration are to be performed programmatically during your plugin's `setup` phase, -using the core `savedObjects`'s `registerType` setup API. - -The most notable difference is that in the new platform, the type registration is performed in a single call to -`registerType`, passing a new `SavedObjectsType` structure that is a superset of the legacy `schema`, `migrations` -`mappings` and `savedObjectsManagement`. - -### Concrete example - -Let say we have the following in a legacy plugin: - -```js -// src/legacy/core_plugins/my_plugin/index.js -import mappings from './mappings.json'; -import { migrations } from './migrations'; - -new kibana.Plugin({ - init(server){ - // [...] - }, - uiExports: { - mappings, - migrations, - savedObjectSchemas: { - 'first-type': { - isNamespaceAgnostic: true, - }, - 'second-type': { - isHidden: true, - }, - }, - savedObjectsManagement: { - 'first-type': { - isImportableAndExportable: true, - icon: 'myFirstIcon', - defaultSearchField: 'title', - getTitle(obj) { - return obj.attributes.title; - }, - getEditUrl(obj) { - return `/some-url/${encodeURIComponent(obj.id)}`; - }, - }, - 'second-type': { - isImportableAndExportable: false, - icon: 'mySecondIcon', - getTitle(obj) { - return obj.attributes.myTitleField; - }, - getInAppUrl(obj) { - return { - path: `/some-url/${encodeURIComponent(obj.id)}`, - uiCapabilitiesPath: 'myPlugin.myType.show', - }; - }, - }, - }, - }, -}) -``` - -```json -// src/legacy/core_plugins/my_plugin/mappings.json -{ - "first-type": { - "properties": { - "someField": { - "type": "text" - }, - "anotherField": { - "type": "text" - } - } - }, - "second-type": { - "properties": { - "textField": { - "type": "text" - }, - "boolField": { - "type": "boolean" - } - } - } -} -``` - -```js -// src/legacy/core_plugins/my_plugin/migrations.js -export const migrations = { - 'first-type': { - '1.0.0': migrateFirstTypeToV1, - '2.0.0': migrateFirstTypeToV2, - }, - 'second-type': { - '1.5.0': migrateSecondTypeToV15, - } -} -``` - -To migrate this, we will have to regroup the declaration per-type. That would become: - -First type: - -```typescript -// src/plugins/my_plugin/server/saved_objects/first_type.ts -import { SavedObjectsType } from 'src/core/server'; - -export const firstType: SavedObjectsType = { - name: 'first-type', - hidden: false, - namespaceType: 'agnostic', - mappings: { - properties: { - someField: { - type: 'text', - }, - anotherField: { - type: 'text', - }, - }, - }, - migrations: { - '1.0.0': migrateFirstTypeToV1, - '2.0.0': migrateFirstTypeToV2, - }, - management: { - importableAndExportable: true, - icon: 'myFirstIcon', - defaultSearchField: 'title', - getTitle(obj) { - return obj.attributes.title; - }, - getEditUrl(obj) { - return `/some-url/${encodeURIComponent(obj.id)}`; - }, - }, -}; -``` - -Second type: - -```typescript -// src/plugins/my_plugin/server/saved_objects/second_type.ts -import { SavedObjectsType } from 'src/core/server'; - -export const secondType: SavedObjectsType = { - name: 'second-type', - hidden: true, - namespaceType: 'single', - mappings: { - properties: { - textField: { - type: 'text', - }, - boolField: { - type: 'boolean', - }, - }, - }, - migrations: { - '1.5.0': migrateSecondTypeToV15, - }, - management: { - importableAndExportable: false, - icon: 'mySecondIcon', - getTitle(obj) { - return obj.attributes.myTitleField; - }, - getInAppUrl(obj) { - return { - path: `/some-url/${encodeURIComponent(obj.id)}`, - uiCapabilitiesPath: 'myPlugin.myType.show', - }; - }, - }, -}; -``` - -Registration in the plugin's setup phase: - -```typescript -// src/plugins/my_plugin/server/plugin.ts -import { firstType, secondType } from './saved_objects'; - -export class MyPlugin implements Plugin { - setup({ savedObjects }) { - savedObjects.registerType(firstType); - savedObjects.registerType(secondType); - } -} -``` - -### Changes in structure compared to legacy - -The NP `registerType` expected input is very close to the legacy format. However, there are some minor changes: - -- The `schema.isNamespaceAgnostic` property has been renamed: `SavedObjectsType.namespaceType`. It no longer accepts a boolean but instead an enum of 'single', 'multiple', or 'agnostic' (see [SavedObjectsNamespaceType](/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md)). - -- The `schema.indexPattern` was accepting either a `string` or a `(config: LegacyConfig) => string`. `SavedObjectsType.indexPattern` only accepts a string, as you can access the configuration during your plugin's setup phase. - -- The `savedObjectsManagement.isImportableAndExportable` property has been renamed: `SavedObjectsType.management.importableAndExportable` - -- The migration function signature has changed: -In legacy, it was `(doc: SavedObjectUnsanitizedDoc, log: SavedObjectsMigrationLogger) => SavedObjectUnsanitizedDoc;` -In new platform, it is now `(doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => SavedObjectUnsanitizedDoc;` - -With context being: - -```typescript -export interface SavedObjectMigrationContext { - log: SavedObjectsMigrationLogger; -} -``` - -The changes is very minor though. The legacy migration: - -```js -const migration = (doc, log) => {...} -``` - -Would be converted to: - -```typescript -const migration: SavedObjectMigrationFn = (doc, { log }) => {...} -``` - -### Remarks - -The `registerType` API will throw if called after the service has started, and therefor cannot be used from -legacy plugin code. Legacy plugins should use the legacy savedObjects service and the legacy way to register -saved object types until migrated. - -## UiSettings -UiSettings defaults registration performed during `setup` phase via `core.uiSettings.register` API. - -```js -// Before: -uiExports: { - uiSettingDefaults: { - 'my-plugin:my-setting': { - name: 'just-work', - value: true, - description: 'make it work', - category: ['my-category'], - }, - } -} -``` - -```ts -// After: -// src/plugins/my-plugin/server/plugin.ts -setup(core: CoreSetup){ - core.uiSettings.register({ - 'my-plugin:my-setting': { - name: 'just-work', - value: true, - description: 'make it work', - category: ['my-category'], - schema: schema.boolean(), - }, - }) -} -``` - -## Elasticsearch client - -The new elasticsearch client is a thin wrapper around `@elastic/elasticsearch`'s `Client` class. Even if the API -is quite close to the legacy client Kibana was previously using, there are some subtle changes to take into account -during migration. - -[Official documentation](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html) - -### Client API Changes - -The most significant changes for the consumers are the following: - -- internal / current user client accessors has been renamed and are now properties instead of functions - - `callAsInternalUser('ping')` -> `asInternalUser.ping()` - - `callAsCurrentUser('ping')` -> `asCurrentUser.ping()` - -- the API now reflects the `Client`'s instead of leveraging the string-based endpoint names the `LegacyAPICaller` was using - -before: - -```ts -const body = await client.callAsInternalUser('indices.get', { index: 'id' }); -``` - -after: - -```ts -const { body } = await client.asInternalUser.indices.get({ index: 'id' }); -``` - -- calling any ES endpoint now returns the whole response object instead of only the body payload - -before: - -```ts -const body = await legacyClient.callAsInternalUser('get', { id: 'id' }); -``` - -after: - -```ts -const { body } = await client.asInternalUser.get({ id: 'id' }); -``` - -Note that more information from the ES response is available: - -```ts -const { - body, // response payload - statusCode, // http status code of the response - headers, // response headers - warnings, // warnings returned from ES - meta // meta information about the request, such as request parameters, number of attempts and so on -} = await client.asInternalUser.get({ id: 'id' }); -``` - -- all API methods are now generic to allow specifying the response body type - -before: - -```ts -const body: GetResponse = await legacyClient.callAsInternalUser('get', { id: 'id' }); -``` - -after: - -```ts -// body is of type `GetResponse` -const { body } = await client.asInternalUser.get({ id: 'id' }); -// fallback to `Record` if unspecified -const { body } = await client.asInternalUser.get({ id: 'id' }); -``` - -- the returned error types changed - -There are no longer specific errors for every HTTP status code (such as `BadRequest` or `NotFound`). A generic -`ResponseError` with the specific `statusCode` is thrown instead. - -before: - -```ts -import { errors } from 'elasticsearch'; -try { - await legacyClient.callAsInternalUser('ping'); -} catch(e) { - if(e instanceof errors.NotFound) { - // do something - } - if(e.status === 401) {} -} -``` - -after: - -```ts -import { errors } from '@elastic/elasticsearch'; -try { - await client.asInternalUser.ping(); -} catch(e) { - if(e instanceof errors.ResponseError && e.statusCode === 404) { - // do something - } - // also possible, as all errors got a name property with the name of the class, - // so this slightly better in term of performances - if(e.name === 'ResponseError' && e.statusCode === 404) { - // do something - } - if(e.statusCode === 401) {...} -} -``` - -- the parameter property names changed from camelCase to snake_case - -Even if technically, the javascript client accepts both formats, the typescript definitions are only defining the snake_case -properties. - -before: - -```ts -legacyClient.callAsCurrentUser('get', { - id: 'id', - storedFields: ['some', 'fields'], -}) -``` - -after: - -```ts -client.asCurrentUser.get({ - id: 'id', - stored_fields: ['some', 'fields'], -}) -``` - -- the request abortion API changed - -All promises returned from the client API calls now have an `abort` method that can be used to cancel the request. - -before: - -```ts -const controller = new AbortController(); -legacyClient.callAsCurrentUser('ping', {}, { - signal: controller.signal, -}) -// later -controller.abort(); -``` - -after: - -```ts -const request = client.asCurrentUser.ping(); -// later -request.abort(); -``` - -- it is now possible to override headers when performing specific API calls. - -Note that doing so is strongly discouraged due to potential side effects with the ES service internal -behavior when scoping as the internal or as the current user. - -```ts -const request = client.asCurrentUser.ping({}, { - headers: { - authorization: 'foo', - custom: 'bar', - } -}); -``` - -- the new client doesn't provide exhaustive typings for the response object yet. You might have to copy -response type definitions from the Legacy Elasticsearch library until https://github.com/elastic/elasticsearch-js/pull/970 merged. - -```ts -// platform provides a few typings for internal purposes -import { SearchResponse } from 'src/core/server'; -type SearchSource = {...}; -type SearchBody = SearchResponse; -const { body } = await client.search(...); -interface Info {...} -const { body } = await client.info(...); -``` - -- Functional tests are subject to migration to the new client as well. -before: -```ts -const client = getService('legacyEs'); -``` - -after: -```ts -const client = getService('es'); -``` - -Please refer to the [Breaking changes list](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/breaking-changes.html) -for more information about the changes between the legacy and new client. - -### Accessing the client from a route handler - -Apart from the API format change, accessing the client from within a route handler -did not change. As it was done for the legacy client, a preconfigured scoped client -bound to the request is accessible using `core` context provider: - -before: - -```ts -router.get( - { - path: '/my-route', - }, - async (context, req, res) => { - const { client } = context.core.elasticsearch.legacy; - // call as current user - const res = await client.callAsCurrentUser('ping'); - // call as internal user - const res2 = await client.callAsInternalUser('search', options); - return res.ok({ body: 'ok' }); - } -); -``` - -after: - -```ts -router.get( - { - path: '/my-route', - }, - async (context, req, res) => { - const { client } = context.core.elasticsearch; - // call as current user - const res = await client.asCurrentUser.ping(); - // call as internal user - const res2 = await client.asInternalUser.search(options); - return res.ok({ body: 'ok' }); - } -); -``` - -### Creating a custom client - -Note that the `plugins` option is now longer available on the new client. As the API is now exhaustive, adding custom -endpoints using plugins should no longer be necessary. - -The API to create custom clients did not change much: - -before: - -```ts -const customClient = coreStart.elasticsearch.legacy.createClient('my-custom-client', customConfig); -// do something with the client, such as -await customClient.callAsInternalUser('ping'); -// custom client are closable -customClient.close(); -``` - -after: - -```ts -const customClient = coreStart.elasticsearch.createClient('my-custom-client', customConfig); -// do something with the client, such as -await customClient.asInternalUser.ping(); -// custom client are closable -customClient.close(); -``` - -If, for any reasons, one still needs to reach an endpoint not listed on the client API, using `request.transport` -is still possible: - -```ts -const { body } = await client.asCurrentUser.transport.request({ - method: 'get', - path: '/my-custom-endpoint', - body: { my: 'payload'}, - querystring: { param: 'foo' } -}) -``` - -Remark: the new client creation API is now only available from the `start` contract of the elasticsearch service. diff --git a/src/core/README.md b/src/core/README.md index 87c42d9c6dab6d..e195bf30c054c8 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -8,7 +8,7 @@ Core Plugin API Documentation: - [Core Server API](/docs/development/core/server/kibana-plugin-core-server.md) - [Conventions for Plugins](./CONVENTIONS.md) - [Testing Kibana Plugins](./TESTING.md) - - [Migration guide for porting existing plugins](./MIGRATION.md) + - [Kibana Platform Plugin API](./docs/developer/architecture/kibana-platform-plugin-api.asciidoc ) Internal Documentation: - [Saved Objects Migrations](./server/saved_objects/migrations/README.md) diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index afcebc06506c26..cd186f87b3a878 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -697,7 +697,7 @@ describe('#start()', () => { // Create an app and a promise that allows us to control when the app completes mounting const createWaitingApp = (props: Partial): [App, () => void] => { let finishMount: () => void; - const mountPromise = new Promise((resolve) => (finishMount = resolve)); + const mountPromise = new Promise((resolve) => (finishMount = resolve)); const app = { id: 'some-id', title: 'some-title', diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index 82933576bc4938..2ccb8ec64f9104 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -66,7 +66,7 @@ describe('ApplicationService', () => { const { register } = service.setup(setupDeps); let resolveMount: () => void; - const promise = new Promise((resolve) => { + const promise = new Promise((resolve) => { resolveMount = resolve; }); @@ -100,7 +100,7 @@ describe('ApplicationService', () => { const { register } = service.setup(setupDeps); let resolveMount: () => void; - const promise = new Promise((resolve) => { + const promise = new Promise((resolve) => { resolveMount = resolve; }); @@ -442,7 +442,7 @@ describe('ApplicationService', () => { const { register } = service.setup(setupDeps); let resolveMount: () => void; - const promise = new Promise((resolve) => { + const promise = new Promise((resolve) => { resolveMount = resolve; }); @@ -480,7 +480,7 @@ describe('ApplicationService', () => { const { register } = service.setup(setupDeps); let resolveMount: () => void; - const promise = new Promise((resolve) => { + const promise = new Promise((resolve) => { resolveMount = resolve; }); diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index f6cde54e6f5025..50c332dacc34a8 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -38,7 +38,7 @@ describe('AppContainer', () => { }); const flushPromises = async () => { - await new Promise(async (resolve) => { + await new Promise(async (resolve) => { setImmediate(() => resolve()); }); }; diff --git a/src/core/public/ui_settings/ui_settings_api.ts b/src/core/public/ui_settings/ui_settings_api.ts index c5efced0a41e3f..175f70a05ec7e1 100644 --- a/src/core/public/ui_settings/ui_settings_api.ts +++ b/src/core/public/ui_settings/ui_settings_api.ts @@ -70,7 +70,7 @@ export class UiSettingsApi { if (error) { reject(error); } else { - resolve(resp); + resolve(resp!); } }, }; diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index cf826eb276252d..35381f49543ae2 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -17,6 +17,5 @@ * under the License. */ -export { shareWeakReplay } from './share_weak_replay'; export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; diff --git a/src/core/public/utils/share_weak_replay.test.ts b/src/core/public/utils/share_weak_replay.test.ts deleted file mode 100644 index beac851aa689c6..00000000000000 --- a/src/core/public/utils/share_weak_replay.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import * as Rx from 'rxjs'; -import { map, materialize, take, toArray } from 'rxjs/operators'; - -import { shareWeakReplay } from './share_weak_replay'; - -let completedCounts = 0; - -function counter({ async = true }: { async?: boolean } = {}) { - let subCounter = 0; - - function sendCount(subscriber: Rx.Subscriber) { - let notifCounter = 0; - const sub = ++subCounter; - - while (!subscriber.closed) { - subscriber.next(`${sub}:${++notifCounter}`); - } - - completedCounts += 1; - } - - return new Rx.Observable((subscriber) => { - if (!async) { - sendCount(subscriber); - return; - } - - const id = setTimeout(() => sendCount(subscriber)); - return () => clearTimeout(id); - }); -} - -async function record(observable: Rx.Observable) { - return observable - .pipe( - materialize(), - map((n) => (n.kind === 'N' ? `N:${n.value}` : n.kind === 'E' ? `E:${n.error.message}` : 'C')), - toArray() - ) - .toPromise(); -} - -afterEach(() => { - completedCounts = 0; -}); - -it('multicasts an observable to multiple children, unsubs once all children do, and resubscribes on next subscription', async () => { - const shared = counter().pipe(shareWeakReplay(1)); - - await expect(Promise.all([record(shared.pipe(take(1))), record(shared.pipe(take(2)))])).resolves - .toMatchInlineSnapshot(` -Array [ - Array [ - "N:1:1", - "C", - ], - Array [ - "N:1:1", - "N:1:2", - "C", - ], -] -`); - - await expect(Promise.all([record(shared.pipe(take(3))), record(shared.pipe(take(4)))])).resolves - .toMatchInlineSnapshot(` -Array [ - Array [ - "N:2:1", - "N:2:2", - "N:2:3", - "C", - ], - Array [ - "N:2:1", - "N:2:2", - "N:2:3", - "N:2:4", - "C", - ], -] -`); - - expect(completedCounts).toBe(2); -}); - -it('resubscribes if parent errors', async () => { - let errorCounter = 0; - const shared = counter().pipe( - map((v, i) => { - if (i === 3) { - throw new Error(`error ${++errorCounter}`); - } - return v; - }), - shareWeakReplay(2) - ); - - await expect(Promise.all([record(shared), record(shared)])).resolves.toMatchInlineSnapshot(` -Array [ - Array [ - "N:1:1", - "N:1:2", - "N:1:3", - "E:error 1", - ], - Array [ - "N:1:1", - "N:1:2", - "N:1:3", - "E:error 1", - ], -] -`); - - await expect(Promise.all([record(shared), record(shared)])).resolves.toMatchInlineSnapshot(` -Array [ - Array [ - "N:2:1", - "N:2:2", - "N:2:3", - "E:error 2", - ], - Array [ - "N:2:1", - "N:2:2", - "N:2:3", - "E:error 2", - ], -] -`); - - expect(completedCounts).toBe(2); -}); - -it('resubscribes if parent completes', async () => { - const shared = counter().pipe(take(4), shareWeakReplay(4)); - - await expect(Promise.all([record(shared.pipe(take(1))), record(shared)])).resolves - .toMatchInlineSnapshot(` -Array [ - Array [ - "N:1:1", - "C", - ], - Array [ - "N:1:1", - "N:1:2", - "N:1:3", - "N:1:4", - "C", - ], -] -`); - - await expect(Promise.all([record(shared.pipe(take(2))), record(shared)])).resolves - .toMatchInlineSnapshot(` -Array [ - Array [ - "N:2:1", - "N:2:2", - "C", - ], - Array [ - "N:2:1", - "N:2:2", - "N:2:3", - "N:2:4", - "C", - ], -] -`); - - expect(completedCounts).toBe(2); -}); - -it('supports parents that complete synchronously', async () => { - const next = jest.fn(); - const complete = jest.fn(); - const shared = counter({ async: false }).pipe(take(3), shareWeakReplay(1)); - - shared.subscribe({ next, complete }); - expect(next.mock.calls).toMatchInlineSnapshot(` -Array [ - Array [ - "1:1", - ], - Array [ - "1:2", - ], - Array [ - "1:3", - ], -] -`); - expect(complete).toHaveBeenCalledTimes(1); - - next.mockClear(); - complete.mockClear(); - - shared.subscribe({ next, complete }); - expect(next.mock.calls).toMatchInlineSnapshot(` -Array [ - Array [ - "2:1", - ], - Array [ - "2:2", - ], - Array [ - "2:3", - ], -] -`); - expect(complete).toHaveBeenCalledTimes(1); - - expect(completedCounts).toBe(2); -}); diff --git a/src/core/public/utils/share_weak_replay.ts b/src/core/public/utils/share_weak_replay.ts deleted file mode 100644 index 5ed6f76c5a05a8..00000000000000 --- a/src/core/public/utils/share_weak_replay.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import * as Rx from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; - -/** - * Just like the [`shareReplay()`](https://rxjs-dev.firebaseapp.com/api/operators/shareReplay) operator from - * RxJS except for a few key differences: - * - * - If all downstream subscribers unsubscribe the source subscription will be unsubscribed. - * - * - Replay-ability is only maintained while the source is active, if it completes or errors - * then complete/error is sent to the current subscribers and the replay buffer is cleared. - * - * - Any subscription after the the source completes or errors will create a new subscription - * to the source observable. - * - * @param bufferSize Optional, default is `Number.POSITIVE_INFINITY` - */ -export function shareWeakReplay(bufferSize?: number): Rx.MonoTypeOperatorFunction { - return (source: Rx.Observable) => { - let subject: Rx.ReplaySubject | undefined; - const stop$ = new Rx.Subject(); - - return new Rx.Observable((observer) => { - if (!subject) { - subject = new Rx.ReplaySubject(bufferSize); - } - - subject.subscribe(observer).add(() => { - if (!subject) { - return; - } - - if (subject.observers.length === 0) { - stop$.next(); - } - - if (subject.closed || subject.isStopped) { - subject = undefined; - } - }); - - if (subject && subject.observers.length === 1) { - source.pipe(takeUntil(stop$)).subscribe(subject); - } - }); - }; -} diff --git a/src/core/server/elasticsearch/client/cluster_client.test.ts b/src/core/server/elasticsearch/client/cluster_client.test.ts index 429fea65704d87..1127619040fff3 100644 --- a/src/core/server/elasticsearch/client/cluster_client.test.ts +++ b/src/core/server/elasticsearch/client/cluster_client.test.ts @@ -419,7 +419,7 @@ describe('ClusterClient', () => { let closeScopedClient: () => void; internalClient.close.mockReturnValue( - new Promise((resolve) => { + new Promise((resolve) => { closeInternalClient = resolve; }).then(() => { expect(clusterClientClosed).toBe(false); @@ -427,7 +427,7 @@ describe('ClusterClient', () => { }) ); scopedClient.close.mockReturnValue( - new Promise((resolve) => { + new Promise((resolve) => { closeScopedClient = resolve; }).then(() => { expect(clusterClientClosed).toBe(false); diff --git a/src/core/server/logging/appenders/file/file_appender.ts b/src/core/server/logging/appenders/file/file_appender.ts index c86ea4972324cf..2d9ac121480687 100644 --- a/src/core/server/logging/appenders/file/file_appender.ts +++ b/src/core/server/logging/appenders/file/file_appender.ts @@ -71,7 +71,7 @@ export class FileAppender implements DisposableAppender { * Disposes `FileAppender`. Waits for the underlying file stream to be completely flushed and closed. */ public async dispose() { - await new Promise((resolve) => { + await new Promise((resolve) => { if (this.outputStream === undefined) { return resolve(); } diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index c26467f4b931ca..8f397c01ffa719 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -20,7 +20,7 @@ import { exportSavedObjectsToStream } from './get_sorted_objects_for_export'; import { savedObjectsClientMock } from '../service/saved_objects_client.mock'; import { Readable } from 'stream'; -import { createPromiseFromStreams, createConcatStream } from '../../utils/streams'; +import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; async function readStreamToCompletion(stream: Readable) { return createPromiseFromStreams([stream, createConcatStream([])]); diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index 7965b12eb874e7..84b14d0a5f02cd 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -18,7 +18,7 @@ */ import Boom from '@hapi/boom'; -import { createListStream } from '../../utils/streams'; +import { createListStream } from '@kbn/utils'; import { SavedObjectsClientContract, SavedObject, diff --git a/src/core/server/saved_objects/import/collect_saved_objects.ts b/src/core/server/saved_objects/import/collect_saved_objects.ts index 8e84f864cf4490..8f09e69f6c7278 100644 --- a/src/core/server/saved_objects/import/collect_saved_objects.ts +++ b/src/core/server/saved_objects/import/collect_saved_objects.ts @@ -23,7 +23,8 @@ import { createFilterStream, createMapStream, createPromiseFromStreams, -} from '../../utils/streams'; +} from '@kbn/utils'; + import { SavedObject } from '../types'; import { createLimitStream } from './create_limit_stream'; import { SavedObjectsImportError } from './types'; diff --git a/src/core/server/saved_objects/import/create_limit_stream.test.ts b/src/core/server/saved_objects/import/create_limit_stream.test.ts index a7e689710a564f..0070a52fdd1c89 100644 --- a/src/core/server/saved_objects/import/create_limit_stream.test.ts +++ b/src/core/server/saved_objects/import/create_limit_stream.test.ts @@ -17,11 +17,7 @@ * under the License. */ -import { - createConcatStream, - createListStream, - createPromiseFromStreams, -} from '../../utils/streams'; +import { createConcatStream, createListStream, createPromiseFromStreams } from '@kbn/utils'; import { createLimitStream } from './create_limit_stream'; describe('createLimitStream()', () => { diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 5b4fd57e11256d..05a91f4aa4c2ce 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -19,7 +19,8 @@ import { schema } from '@kbn/config-schema'; import stringify from 'json-stable-stringify'; -import { createPromiseFromStreams, createMapStream, createConcatStream } from '../../utils/streams'; +import { createPromiseFromStreams, createMapStream, createConcatStream } from '@kbn/utils'; + import { IRouter } from '../../http'; import { SavedObjectConfig } from '../saved_objects_config'; import { exportSavedObjectsToStream } from '../export'; diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index d0fcd4b8b66df3..07bf320c29496d 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -22,9 +22,9 @@ jest.mock('../../export', () => ({ })); import * as exportMock from '../../export'; -import { createListStream } from '../../../utils/streams'; import supertest from 'supertest'; -import { UnwrapPromise } from '@kbn/utility-types'; +import type { UnwrapPromise } from '@kbn/utility-types'; +import { createListStream } from '@kbn/utils'; import { SavedObjectConfig } from '../../saved_objects_config'; import { registerExportRoute } from '../export'; import { setupServer, createExportableType } from '../test_utils'; diff --git a/src/core/server/saved_objects/routes/utils.test.ts b/src/core/server/saved_objects/routes/utils.test.ts index 693513dfc7c408..eaa9a42821e489 100644 --- a/src/core/server/saved_objects/routes/utils.test.ts +++ b/src/core/server/saved_objects/routes/utils.test.ts @@ -19,7 +19,7 @@ import { createSavedObjectsStreamFromNdJson, validateTypes, validateObjects } from './utils'; import { Readable } from 'stream'; -import { createPromiseFromStreams, createConcatStream } from '../../utils/streams'; +import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; async function readStreamToCompletion(stream: Readable) { return createPromiseFromStreams([stream, createConcatStream([])]); diff --git a/src/core/server/saved_objects/routes/utils.ts b/src/core/server/saved_objects/routes/utils.ts index 6536406d116d7d..83cb2ef75bd552 100644 --- a/src/core/server/saved_objects/routes/utils.ts +++ b/src/core/server/saved_objects/routes/utils.ts @@ -26,7 +26,7 @@ import { createPromiseFromStreams, createListStream, createConcatStream, -} from '../../utils/streams'; +} from '@kbn/utils'; export async function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) { const savedObjects = await createPromiseFromStreams([ diff --git a/src/core/server/utils/index.ts b/src/core/server/utils/index.ts index d9c4217c4117f7..b01a4c4e048998 100644 --- a/src/core/server/utils/index.ts +++ b/src/core/server/utils/index.ts @@ -20,4 +20,3 @@ export * from './crypto'; export * from './from_root'; export * from './package_json'; -export * from './streams'; diff --git a/src/core/server/utils/streams/index.ts b/src/core/server/utils/streams/index.ts deleted file mode 100644 index 447d1ed5b1c535..00000000000000 --- a/src/core/server/utils/streams/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { concatStreamProviders } from './concat_stream_providers'; -export { createIntersperseStream } from './intersperse_stream'; -export { createSplitStream } from './split_stream'; -export { createListStream } from './list_stream'; -export { createReduceStream } from './reduce_stream'; -export { createPromiseFromStreams } from './promise_from_streams'; -export { createConcatStream } from './concat_stream'; -export { createMapStream } from './map_stream'; -export { createReplaceStream } from './replace_stream'; -export { createFilterStream } from './filter_stream'; diff --git a/src/dev/build/lib/watch_stdio_for_line.ts b/src/dev/build/lib/watch_stdio_for_line.ts index c97b1c3b26db55..38e0a93ae131fc 100644 --- a/src/dev/build/lib/watch_stdio_for_line.ts +++ b/src/dev/build/lib/watch_stdio_for_line.ts @@ -20,11 +20,7 @@ import { Transform } from 'stream'; import { ExecaChildProcess } from 'execa'; -import { - createPromiseFromStreams, - createSplitStream, - createMapStream, -} from '../../../core/server/utils'; +import { createPromiseFromStreams, createSplitStream, createMapStream } from '@kbn/utils'; // creates a stream that skips empty lines unless they are followed by // another line, preventing the empty lines produced by splitStream diff --git a/src/dev/build/tasks/os_packages/service_templates/systemd/etc/systemd/system/kibana.service b/src/dev/build/tasks/os_packages/service_templates/systemd/usr/lib/systemd/system/kibana.service similarity index 100% rename from src/dev/build/tasks/os_packages/service_templates/systemd/etc/systemd/system/kibana.service rename to src/dev/build/tasks/os_packages/service_templates/systemd/usr/lib/systemd/system/kibana.service diff --git a/src/dev/build/tasks/patch_native_modules_task.ts b/src/dev/build/tasks/patch_native_modules_task.ts index c3011fa80988c4..b6eda2dbfd560f 100644 --- a/src/dev/build/tasks/patch_native_modules_task.ts +++ b/src/dev/build/tasks/patch_native_modules_task.ts @@ -46,15 +46,30 @@ const packages: Package[] = [ destinationPath: 'node_modules/re2/build/Release/re2.node', extractMethod: 'gunzip', archives: { - darwin: { + 'darwin-x64': { url: 'https://github.com/uhop/node-re2/releases/download/1.15.4/darwin-x64-72.gz', sha256: '983106049bb86e21b7f823144b2b83e3f1408217401879b3cde0312c803512c9', }, - linux: { + 'linux-x64': { url: 'https://github.com/uhop/node-re2/releases/download/1.15.4/linux-x64-72.gz', sha256: '8b6692037f7b0df24dabc9c9b039038d1c3a3110f62121616b406c482169710a', }, - win32: { + + // ARM build is currently done manually as Github Actions used in upstream project + // do not natively support an ARM target. + + // From a AWS Graviton instance: + // * checkout the node-re2 project, + // * install Node using the same minor used by Kibana + // * npm install, which will also create a build + // * gzip -c build/Release/re2.node > linux-arm64-72.gz + // * upload to kibana-ci-proxy-cache bucket + 'linux-arm64': { + url: + 'https://storage.googleapis.com/kibana-ci-proxy-cache/node-re2/uhop/node-re2/releases/download/1.15.4/linux-arm64-72.gz', + sha256: '5942353ec9cf46a39199818d474f7af137cfbb1bc5727047fe22f31f36602a7e', + }, + 'win32-x64': { url: 'https://github.com/uhop/node-re2/releases/download/1.15.4/win32-x64-72.gz', sha256: '0a6991e693577160c3e9a3f196bd2518368c52d920af331a1a183313e0175604', }, @@ -84,7 +99,7 @@ async function patchModule( `Can't patch ${pkg.name}'s native module, we were expecting version ${pkg.version} and found ${installedVersion}` ); } - const platformName = platform.getName(); + const platformName = platform.getNodeArch(); const archive = pkg.archives[platformName]; const archiveName = path.basename(archive.url); const downloadPath = config.resolveFromRepo(DOWNLOAD_DIRECTORY, pkg.name, archiveName); diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx index e9fa2833c3db5f..ad938d339f681e 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx @@ -263,7 +263,7 @@ export class Field extends PureComponent { return new Promise((resolve, reject) => { reader.onload = () => { - resolve(reader.result || undefined); + resolve(reader.result!); }; reader.onerror = (err) => { reject(err); diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts index da6c940c48d0ae..d7dde8f1b93d33 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts @@ -30,7 +30,7 @@ const getPromiseState = (promise: Promise): Promise<'resolved' | 'rejec () => resolve('rejected') ) ), - new Promise<'pending'>((resolve) => resolve()).then(() => 'pending'), + new Promise<'pending'>((resolve) => resolve('pending')).then(() => 'pending'), ]); const isPending = (promise: Promise): Promise => diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts index 84b12c97f1856b..34f43886df66e7 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts @@ -74,7 +74,7 @@ export class LegacyCoreEditor implements CoreEditor { // dirty check for tokenizer state, uses a lot less cycles // than listening for tokenizerUpdate waitForLatestTokens(): Promise { - return new Promise((resolve) => { + return new Promise((resolve) => { const session = this.editor.getSession(); const checkInterval = 25; @@ -239,7 +239,7 @@ export class LegacyCoreEditor implements CoreEditor { private forceRetokenize() { const session = this.editor.getSession(); - return new Promise((resolve) => { + return new Promise((resolve) => { // force update of tokens, but not on this thread to allow for ace rendering. setTimeout(function () { let i; diff --git a/src/plugins/console/server/lib/proxy_request.ts b/src/plugins/console/server/lib/proxy_request.ts index 27e19d920ad174..b6d7fc97f49a8b 100644 --- a/src/plugins/console/server/lib/proxy_request.ts +++ b/src/plugins/console/server/lib/proxy_request.ts @@ -110,7 +110,7 @@ export const proxyRequest = ({ if (!resolved) { timeoutReject(Boom.gatewayTimeout('Client request timeout')); } else { - timeoutResolve(); + timeoutResolve(undefined); } }, timeout); }); diff --git a/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx b/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx index 933d2766d13f47..dcce38cdf94cec 100644 --- a/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx @@ -20,7 +20,11 @@ import { i18n } from '@kbn/i18n'; import { IEmbeddable } from '../../embeddable_plugin'; import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; -import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable'; +import { + DASHBOARD_CONTAINER_TYPE, + DashboardContainer, + DashboardContainerInput, +} from '../embeddable'; export const ACTION_EXPAND_PANEL = 'togglePanel'; @@ -33,7 +37,9 @@ function isExpanded(embeddable: IEmbeddable) { throw new IncompatibleActionError(); } - return embeddable.id === embeddable.parent.getInput().expandedPanelId; + return ( + embeddable.id === (embeddable.parent.getInput() as DashboardContainerInput).expandedPanelId + ); } export interface ExpandPanelActionContext { diff --git a/src/plugins/data/common/exports/export_csv.test.ts b/src/plugins/data/common/exports/export_csv.test.ts new file mode 100644 index 00000000000000..73878111b1479e --- /dev/null +++ b/src/plugins/data/common/exports/export_csv.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { Datatable } from 'src/plugins/expressions'; +import { FieldFormat } from '../../common/field_formats'; +import { datatableToCSV } from './export_csv'; + +function getDefaultOptions() { + const formatFactory = jest.fn(); + formatFactory.mockReturnValue({ convert: (v: unknown) => `Formatted_${v}` } as FieldFormat); + return { + csvSeparator: ',', + quoteValues: true, + formatFactory, + }; +} + +function getDataTable({ multipleColumns }: { multipleColumns?: boolean } = {}): Datatable { + const layer1: Datatable = { + type: 'datatable', + columns: [{ id: 'col1', name: 'columnOne', meta: { type: 'string' } }], + rows: [{ col1: 'value' }], + }; + if (multipleColumns) { + layer1.columns.push({ id: 'col2', name: 'columnTwo', meta: { type: 'number' } }); + layer1.rows[0].col2 = 5; + } + return layer1; +} + +describe('CSV exporter', () => { + test('should not break with empty data', () => { + expect( + datatableToCSV({ type: 'datatable', columns: [], rows: [] }, getDefaultOptions()) + ).toMatch(''); + }); + + test('should export formatted values by default', () => { + expect(datatableToCSV(getDataTable(), getDefaultOptions())).toMatch( + 'columnOne\r\n"Formatted_value"\r\n' + ); + }); + + test('should not quote values when requested', () => { + return expect( + datatableToCSV(getDataTable(), { ...getDefaultOptions(), quoteValues: false }) + ).toMatch('columnOne\r\nFormatted_value\r\n'); + }); + + test('should use raw values when requested', () => { + expect(datatableToCSV(getDataTable(), { ...getDefaultOptions(), raw: true })).toMatch( + 'columnOne\r\nvalue\r\n' + ); + }); + + test('should use separator for multiple columns', () => { + expect(datatableToCSV(getDataTable({ multipleColumns: true }), getDefaultOptions())).toMatch( + 'columnOne,columnTwo\r\n"Formatted_value","Formatted_5"\r\n' + ); + }); + + test('should escape values', () => { + const datatable = getDataTable(); + datatable.rows[0].col1 = '"value"'; + expect(datatableToCSV(datatable, getDefaultOptions())).toMatch( + 'columnOne\r\n"Formatted_""value"""\r\n' + ); + }); +}); diff --git a/src/plugins/data/common/exports/export_csv.tsx b/src/plugins/data/common/exports/export_csv.tsx new file mode 100644 index 00000000000000..1e1420c245eb4e --- /dev/null +++ b/src/plugins/data/common/exports/export_csv.tsx @@ -0,0 +1,82 @@ +/* + * 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. + */ + +// Inspired by the inspector CSV exporter + +import { FormatFactory } from 'src/plugins/data/common/field_formats/utils'; +import { Datatable } from 'src/plugins/expressions'; + +const LINE_FEED_CHARACTER = '\r\n'; +const nonAlphaNumRE = /[^a-zA-Z0-9]/; +const allDoubleQuoteRE = /"/g; +export const CSV_MIME_TYPE = 'text/plain;charset=utf-8'; + +// TODO: enhance this later on +function escape(val: object | string, quoteValues: boolean) { + if (val != null && typeof val === 'object') { + val = val.valueOf(); + } + + val = String(val); + + if (quoteValues && nonAlphaNumRE.test(val)) { + val = `"${val.replace(allDoubleQuoteRE, '""')}"`; + } + + return val; +} + +interface CSVOptions { + csvSeparator: string; + quoteValues: boolean; + formatFactory: FormatFactory; + raw?: boolean; +} + +export function datatableToCSV( + { columns, rows }: Datatable, + { csvSeparator, quoteValues, formatFactory, raw }: CSVOptions +) { + // Build the header row by its names + const header = columns.map((col) => escape(col.name, quoteValues)); + + const formatters = columns.reduce>>( + (memo, { id, meta }) => { + memo[id] = formatFactory(meta?.params); + return memo; + }, + {} + ); + + // Convert the array of row objects to an array of row arrays + const csvRows = rows.map((row) => { + return columns.map((column) => + escape(raw ? row[column.id] : formatters[column.id].convert(row[column.id]), quoteValues) + ); + }); + + if (header.length === 0) { + return ''; + } + + return ( + [header, ...csvRows].map((row) => row.join(csvSeparator)).join(LINE_FEED_CHARACTER) + + LINE_FEED_CHARACTER + ); // Add \r\n after last line +} diff --git a/packages/kbn-legacy-logging/src/test_utils/index.ts b/src/plugins/data/common/exports/index.ts similarity index 91% rename from packages/kbn-legacy-logging/src/test_utils/index.ts rename to src/plugins/data/common/exports/index.ts index f13c869b563a29..72faac654b421c 100644 --- a/packages/kbn-legacy-logging/src/test_utils/index.ts +++ b/src/plugins/data/common/exports/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { createListStream, createPromiseFromStreams } from './streams'; +export { datatableToCSV, CSV_MIME_TYPE } from './export_csv'; diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index 2d6637daf4324e..36129a4d3f8cd4 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -26,6 +26,7 @@ export * from './query'; export * from './search'; export * from './types'; export * from './utils'; +export * from './exports'; /** * Use data plugin interface instead diff --git a/src/plugins/data/common/search/aggs/buckets/index.ts b/src/plugins/data/common/search/aggs/buckets/index.ts index b16242e5198721..04a748bfb19651 100644 --- a/src/plugins/data/common/search/aggs/buckets/index.ts +++ b/src/plugins/data/common/search/aggs/buckets/index.ts @@ -35,3 +35,4 @@ export * from './lib/ip_range'; export * from './migrate_include_exclude_format'; export * from './significant_terms'; export * from './terms'; +export * from './lib/time_buckets/calc_auto_interval'; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 129addf3de70ee..e0b0c5a0ea980f 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -212,6 +212,16 @@ export { FieldFormat, } from '../common'; +/** + * Exporters (CSV) + */ + +import { datatableToCSV, CSV_MIME_TYPE } from '../common'; +export const exporters = { + datatableToCSV, + CSV_MIME_TYPE, +}; + /* * Index patterns: */ diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 5a707393b39f42..2c47ecb27184d8 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -17,7 +17,8 @@ import { CoreSetup } from 'src/core/public'; import { CoreSetup as CoreSetup_2 } from 'kibana/public'; import { CoreStart } from 'kibana/public'; import { CoreStart as CoreStart_2 } from 'src/core/public'; -import { Datatable as Datatable_2 } from 'src/plugins/expressions/common'; +import { Datatable as Datatable_2 } from 'src/plugins/expressions'; +import { Datatable as Datatable_3 } from 'src/plugins/expressions/common'; import { DatatableColumn as DatatableColumn_2 } from 'src/plugins/expressions'; import { Ensure } from '@kbn/utility-types'; import { EnvironmentMode } from '@kbn/config'; @@ -35,6 +36,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { ExpressionFunctionDefinition as ExpressionFunctionDefinition_2 } from 'src/plugins/expressions/public'; import { ExpressionsSetup } from 'src/plugins/expressions/public'; import { ExpressionValueBoxed } from 'src/plugins/expressions/common'; +import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_formats/utils'; import { History } from 'history'; import { Href } from 'history'; import { IconType } from '@elastic/eui'; @@ -672,6 +674,14 @@ export type ExistsFilter = Filter & { exists?: FilterExistsProperty; }; +// Warning: (ae-missing-release-tag) "exporters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const exporters: { + datatableToCSV: typeof datatableToCSV; + CSV_MIME_TYPE: string; +}; + // Warning: (ae-missing-release-tag) "ExpressionFunctionKibana" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2037,8 +2047,8 @@ export const search: { // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "screenTitle" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "indexPatterns" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; }; // Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" needs to be exported by the entry point index.d.ts @@ -2392,27 +2402,28 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:413:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:220:23 - (ae-forgotten-export) The symbol "datatableToCSV" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:415:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:426:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:427:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:430:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:45:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index e24869f5237ead..b3fe412152c9d5 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -49,6 +49,16 @@ export const esFilters = { isFilterDisabled, }; +/** + * Exporters (CSV) + */ + +import { datatableToCSV, CSV_MIME_TYPE } from '../common'; +export const exporters = { + datatableToCSV, + CSV_MIME_TYPE, +}; + /* * esQuery and esKuery: */ @@ -186,6 +196,7 @@ import { includeTotalLoaded, toKibanaSearchResponse, getTotalLoaded, + calcAutoIntervalLessThan, } from '../common'; export { @@ -272,6 +283,7 @@ export const search = { siblingPipelineType, termsAggFilter, toAbsoluteDates, + calcAutoIntervalLessThan, }, getRequestInspectorStats, getResponseInspectorStats, diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 94114288eb1f3f..6870ad5e2402f9 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -14,7 +14,8 @@ import { CoreSetup } from 'src/core/server'; import { CoreSetup as CoreSetup_2 } from 'kibana/server'; import { CoreStart } from 'src/core/server'; import { CoreStart as CoreStart_2 } from 'kibana/server'; -import { Datatable } from 'src/plugins/expressions/common'; +import { Datatable } from 'src/plugins/expressions'; +import { Datatable as Datatable_2 } from 'src/plugins/expressions/common'; import { DatatableColumn } from 'src/plugins/expressions'; import { Duration } from 'moment'; import { ElasticsearchClient } from 'src/core/server'; @@ -27,6 +28,7 @@ import { ExpressionAstFunction } from 'src/plugins/expressions/common'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { ExpressionValueBoxed } from 'src/plugins/expressions/common'; +import { FormatFactory } from 'src/plugins/data/common/field_formats/utils'; import { ISavedObjectsRepository } from 'src/core/server'; import { IScopedClusterClient } from 'src/core/server'; import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public'; @@ -299,6 +301,14 @@ export type ExecutionContextSearch = { timeRange?: TimeRange; }; +// Warning: (ae-missing-release-tag) "exporters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const exporters: { + datatableToCSV: typeof datatableToCSV; + CSV_MIME_TYPE: string; +}; + // Warning: (ae-missing-release-tag) "ExpressionFunctionKibana" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1074,6 +1084,7 @@ export const search: { siblingPipelineType: string; termsAggFilter: string[]; toAbsoluteDates: typeof toAbsoluteDates; + calcAutoIntervalLessThan: typeof calcAutoIntervalLessThan; }; getRequestInspectorStats: typeof getRequestInspectorStats; getResponseInspectorStats: typeof getResponseInspectorStats; @@ -1216,40 +1227,42 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:135:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildCustomFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:258:5 - (ae-forgotten-export) The symbol "getTotalLoaded" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:259:5 - (ae-forgotten-export) The symbol "toSnakeCase" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:263:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:264:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:273:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:274:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:275:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:279:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:280:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:284:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:287:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:57:23 - (ae-forgotten-export) The symbol "datatableToCSV" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:81:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:81:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:137:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:137:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:269:5 - (ae-forgotten-export) The symbol "getTotalLoaded" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:270:5 - (ae-forgotten-export) The symbol "toSnakeCase" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:274:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:275:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:284:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:285:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:286:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:290:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:291:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:295:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:298:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:299:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index_patterns/index_patterns_service.ts:58:14 - (ae-forgotten-export) The symbol "IndexPatternsService" needs to be exported by the entry point index.d.ts // src/plugins/data/server/plugin.ts:88:66 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts // src/plugins/data/server/search/types.ts:104:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx b/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx index 4a539b618f817f..e44c05b3a88a95 100644 --- a/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx +++ b/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx @@ -47,7 +47,7 @@ export function ChangeIndexPattern({ indexPatternRefs: IndexPatternRef[]; onChangeIndexPattern: (newId: string) => void; indexPatternId?: string; - selectableProps?: EuiSelectableProps; + selectableProps?: EuiSelectableProps<{ value: string }>; }) { const [isPopoverOpen, setPopoverIsOpen] = useState(false); @@ -86,7 +86,7 @@ export function ChangeIndexPattern({ defaultMessage: 'Change index pattern', })} - data-test-subj="indexPattern-switcher" {...selectableProps} searchable diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 292d7d3bf7a1e3..1426ade147d307 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -171,7 +171,7 @@ export abstract class Container< return this.children[id] as TEmbeddable; } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const subscription = merge(this.getOutput$(), this.getInput$()).subscribe(() => { if (this.output.embeddableLoaded[id]) { subscription.unsubscribe(); @@ -181,6 +181,7 @@ export abstract class Container< // If we hit this, the panel was removed before the embeddable finished loading. if (this.input.panels[id] === undefined) { subscription.unsubscribe(); + // @ts-expect-error undefined in not assignable to TEmbeddable | ErrorEmbeddable resolve(undefined); } }); diff --git a/src/plugins/embeddable/public/lib/state_transfer/types.ts b/src/plugins/embeddable/public/lib/state_transfer/types.ts index d8b4f4801bba3c..d36954528dbf02 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/types.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/types.ts @@ -53,7 +53,7 @@ export function isEmbeddablePackageState(state: unknown): state is EmbeddablePac function ensureFieldOfTypeExists(key: string, obj: unknown, type?: string): boolean { return ( - obj && + Boolean(obj) && key in (obj as { [key: string]: unknown }) && (!type || typeof (obj as { [key: string]: unknown })[key] === type) ); diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx index 893b6b04e50bcd..b6a7137c1e421c 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx @@ -56,6 +56,7 @@ export class ContactCardEmbeddableFactory { modalSession.close(); + // @ts-expect-error resolve(undefined); }} onCreate={(input: { firstName: string; lastName?: string }) => { diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index 869d1fac54b1ea..a509331ef1900b 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -149,7 +149,7 @@ export function useForm( return; } - return new Promise((resolve) => { + return new Promise((resolve) => { setTimeout(() => { areSomeFieldValidating = fieldsToArray().some((field) => field.isValidating); if (areSomeFieldValidating) { diff --git a/src/plugins/expressions/common/expression_renderers/types.ts b/src/plugins/expressions/common/expression_renderers/types.ts index 0ea3d72e756092..dd3124c7d17ee3 100644 --- a/src/plugins/expressions/common/expression_renderers/types.ts +++ b/src/plugins/expressions/common/expression_renderers/types.ts @@ -61,6 +61,18 @@ export interface ExpressionRenderDefinition { export type AnyExpressionRenderDefinition = ExpressionRenderDefinition; +/** + * Mode of the expression render environment. + * This value can be set from a consumer embedding an expression renderer and is accessible + * from within the active render function as part of the handlers. + * The following modes are supported: + * * display (default): The chart is rendered in a container with the main purpose of viewing the chart (e.g. in a container like dashboard or canvas) + * * preview: The chart is rendered in very restricted space (below 100px width and height) and should only show a rough outline + * * edit: The chart is rendered within an editor and configuration elements within the chart should be displayed + * * noInteractivity: The chart is rendered in a non-interactive environment and should not provide any affordances for interaction like brushing + */ +export type RenderMode = 'noInteractivity' | 'edit' | 'preview' | 'display'; + export interface IInterpreterRenderHandlers { /** * Done increments the number of rendering successes @@ -70,5 +82,6 @@ export interface IInterpreterRenderHandlers { reload: () => void; update: (params: any) => void; event: (event: any) => void; + getRenderMode: () => RenderMode; uiState?: PersistedState; } diff --git a/src/plugins/expressions/common/expression_types/expression_type.test.ts b/src/plugins/expressions/common/expression_types/expression_type.test.ts index b94d9a305121fe..2976697e0299f3 100644 --- a/src/plugins/expressions/common/expression_types/expression_type.test.ts +++ b/src/plugins/expressions/common/expression_types/expression_type.test.ts @@ -44,7 +44,7 @@ export const render: ExpressionTypeDefinition<'render', ExpressionValueRender(v: T): ExpressionValueRender => ({ - type: name, + type: 'render', as: 'debug', value: v, }), diff --git a/src/plugins/expressions/public/loader.test.ts b/src/plugins/expressions/public/loader.test.ts index bf8b4427695638..598b614a326a9d 100644 --- a/src/plugins/expressions/public/loader.test.ts +++ b/src/plugins/expressions/public/loader.test.ts @@ -20,17 +20,24 @@ import { first, skip, toArray } from 'rxjs/operators'; import { loader, ExpressionLoader } from './loader'; import { Observable } from 'rxjs'; -import { parseExpression, IInterpreterRenderHandlers } from '../common'; +import { + parseExpression, + IInterpreterRenderHandlers, + RenderMode, + AnyExpressionFunctionDefinition, +} from '../common'; // eslint-disable-next-line -const { __getLastExecution } = require('./services'); +const { __getLastExecution, __getLastRenderMode } = require('./services'); const element: HTMLElement = null as any; jest.mock('./services', () => { + let renderMode: RenderMode | undefined; const renderers: Record = { test: { render: (el: HTMLElement, value: unknown, handlers: IInterpreterRenderHandlers) => { + renderMode = handlers.getRenderMode(); handlers.done(); }, }, @@ -39,9 +46,18 @@ jest.mock('./services', () => { // eslint-disable-next-line const service = new (require('../common/service/expressions_services').ExpressionsService as any)(); + const testFn: AnyExpressionFunctionDefinition = { + fn: () => ({ type: 'render', as: 'test' }), + name: 'testrender', + args: {}, + help: '', + }; + service.registerFunction(testFn); + const moduleMock = { __execution: undefined, __getLastExecution: () => moduleMock.__execution, + __getLastRenderMode: () => renderMode, getRenderersRegistry: () => ({ get: (id: string) => renderers[id], }), @@ -130,6 +146,14 @@ describe('ExpressionLoader', () => { expect(response).toBe(2); }); + it('passes mode to the renderer', async () => { + const expressionLoader = new ExpressionLoader(element, 'testrender', { + renderMode: 'edit', + }); + await expressionLoader.render$.pipe(first()).toPromise(); + expect(__getLastRenderMode()).toEqual('edit'); + }); + it('cancels the previous request when the expression is updated', () => { const expressionLoader = new ExpressionLoader(element, 'var foo', {}); const execution = __getLastExecution(); diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 91c482621de36c..983a344c0e1a16 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -63,6 +63,7 @@ export class ExpressionLoader { this.renderHandler = new ExpressionRenderHandler(element, { onRenderError: params && params.onRenderError, + renderMode: params?.renderMode, }); this.render$ = this.renderHandler.render$; this.update$ = this.renderHandler.update$; diff --git a/src/plugins/expressions/public/public.api.md b/src/plugins/expressions/public/public.api.md index 17f8e6255f6bb0..2a73cd6e208d1f 100644 --- a/src/plugins/expressions/public/public.api.md +++ b/src/plugins/expressions/public/public.api.md @@ -530,7 +530,7 @@ export interface ExpressionRenderError extends Error { // @public (undocumented) export class ExpressionRenderHandler { // Warning: (ae-forgotten-export) The symbol "ExpressionRenderHandlerParams" needs to be exported by the entry point index.d.ts - constructor(element: HTMLElement, { onRenderError }?: Partial); + constructor(element: HTMLElement, { onRenderError, renderMode }?: Partial); // (undocumented) destroy: () => void; // (undocumented) @@ -891,6 +891,10 @@ export interface IExpressionLoaderParams { // // (undocumented) onRenderError?: RenderErrorHandlerFnType; + // Warning: (ae-forgotten-export) The symbol "RenderMode" needs to be exported by the entry point index.d.ts + // + // (undocumented) + renderMode?: RenderMode; // (undocumented) searchContext?: SerializableState_2; // (undocumented) @@ -909,6 +913,8 @@ export interface IInterpreterRenderHandlers { // (undocumented) event: (event: any) => void; // (undocumented) + getRenderMode: () => RenderMode; + // (undocumented) onDestroy: (fn: () => void) => void; // (undocumented) reload: () => void; diff --git a/src/plugins/expressions/public/render.test.ts b/src/plugins/expressions/public/render.test.ts index 97a37d49147ec9..c44683f6779c05 100644 --- a/src/plugins/expressions/public/render.test.ts +++ b/src/plugins/expressions/public/render.test.ts @@ -129,7 +129,7 @@ describe('ExpressionRenderHandler', () => { it('sends a next observable once rendering is complete', () => { const expressionRenderHandler = new ExpressionRenderHandler(element); expect.assertions(1); - return new Promise((resolve) => { + return new Promise((resolve) => { expressionRenderHandler.render$.subscribe((renderCount) => { expect(renderCount).toBe(1); resolve(); diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts index 924f8d4830f733..4390033b5be606 100644 --- a/src/plugins/expressions/public/render.ts +++ b/src/plugins/expressions/public/render.ts @@ -22,7 +22,7 @@ import { Observable } from 'rxjs'; import { filter } from 'rxjs/operators'; import { ExpressionRenderError, RenderErrorHandlerFnType, IExpressionLoaderParams } from './types'; import { renderErrorHandler as defaultRenderErrorHandler } from './render_error_handler'; -import { IInterpreterRenderHandlers, ExpressionAstExpression } from '../common'; +import { IInterpreterRenderHandlers, ExpressionAstExpression, RenderMode } from '../common'; import { getRenderersRegistry } from './services'; @@ -30,6 +30,7 @@ export type IExpressionRendererExtraHandlers = Record; export interface ExpressionRenderHandlerParams { onRenderError: RenderErrorHandlerFnType; + renderMode: RenderMode; } export interface ExpressionRendererEvent { @@ -58,7 +59,7 @@ export class ExpressionRenderHandler { constructor( element: HTMLElement, - { onRenderError }: Partial = {} + { onRenderError, renderMode }: Partial = {} ) { this.element = element; @@ -92,6 +93,9 @@ export class ExpressionRenderHandler { event: (data) => { this.eventsSubject.next(data); }, + getRenderMode: () => { + return renderMode || 'display'; + }, }; } diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index 4af36fea169a1b..5bae9856994768 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -23,6 +23,7 @@ import { ExpressionValue, ExpressionsService, SerializableState, + RenderMode, } from '../../common'; /** @@ -54,6 +55,7 @@ export interface IExpressionLoaderParams { inspectorAdapters?: Adapters; onRenderError?: RenderErrorHandlerFnType; searchSessionId?: string; + renderMode?: RenderMode; } export interface ExpressionRenderError extends Error { diff --git a/src/plugins/expressions/server/server.api.md b/src/plugins/expressions/server/server.api.md index e5b499206ebdd8..33ff759faa3b1a 100644 --- a/src/plugins/expressions/server/server.api.md +++ b/src/plugins/expressions/server/server.api.md @@ -729,6 +729,10 @@ export interface IInterpreterRenderHandlers { done: () => void; // (undocumented) event: (event: any) => void; + // Warning: (ae-forgotten-export) The symbol "RenderMode" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getRenderMode: () => RenderMode; // (undocumented) onDestroy: (fn: () => void) => void; // (undocumented) diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/ensure_minimum_time.test.ts b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/ensure_minimum_time.test.ts index a38f3cbd8fe815..5a202bff53b644 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/ensure_minimum_time.test.ts +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/ensure_minimum_time.test.ts @@ -38,7 +38,7 @@ describe('ensureMinimumTime', () => { it('resolves in the amount of time provided, at minimum', async (done) => { const startTime = new Date().getTime(); - const promise = new Promise((resolve) => resolve()); + const promise = new Promise((resolve) => resolve()); await ensureMinimumTime(promise, 100); const endTime = new Date().getTime(); expect(endTime - startTime).toBeGreaterThanOrEqual(100); diff --git a/src/plugins/kibana_utils/common/of.test.ts b/src/plugins/kibana_utils/common/of.test.ts index 9ff8997f637e56..a262bfa708d0aa 100644 --- a/src/plugins/kibana_utils/common/of.test.ts +++ b/src/plugins/kibana_utils/common/of.test.ts @@ -21,7 +21,7 @@ import { of } from './of'; describe('of()', () => { describe('when promise resolves', () => { - const promise = new Promise((resolve) => resolve()).then(() => 123); + const promise = new Promise((resolve) => resolve()).then(() => 123); test('first member of 3-tuple is the promise value', async () => { const [result] = await of(promise); @@ -40,7 +40,7 @@ describe('of()', () => { }); describe('when promise rejects', () => { - const promise = new Promise((resolve) => resolve()).then(() => { + const promise = new Promise((resolve) => resolve()).then(() => { // eslint-disable-next-line no-throw-literal throw 123; }); diff --git a/src/plugins/maps_legacy/common/ems_defaults.ts b/src/plugins/maps_legacy/common/ems_defaults.ts index 583dca1dbf036c..d1ae9e7983bcd5 100644 --- a/src/plugins/maps_legacy/common/ems_defaults.ts +++ b/src/plugins/maps_legacy/common/ems_defaults.ts @@ -20,6 +20,6 @@ // Default config for the elastic hosted EMS endpoints export const DEFAULT_EMS_FILE_API_URL = 'https://vector.maps.elastic.co'; export const DEFAULT_EMS_TILE_API_URL = 'https://tiles.maps.elastic.co'; -export const DEFAULT_EMS_LANDING_PAGE_URL = 'https://maps.elastic.co/v7.10'; +export const DEFAULT_EMS_LANDING_PAGE_URL = 'https://maps.elastic.co/v7.11'; export const DEFAULT_EMS_FONT_LIBRARY_URL = 'https://tiles.maps.elastic.co/fonts/{fontstack}/{range}.pbf'; diff --git a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts index e29922c2481c43..87a3fd8f5b4997 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts @@ -60,6 +60,7 @@ const createApiUiMock = (): SavedObjectsTaggingApiUiMock => { convertNameToReference: jest.fn(), parseSearchQuery: jest.fn(), getTagIdsFromReferences: jest.fn(), + getTagIdFromName: jest.fn(), updateTagsReferences: jest.fn(), }; diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index 71548cd5c7f510..81f7cc9326a77f 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -84,7 +84,7 @@ export interface SavedObjectsTaggingApiUi { /** * Convert given tag name to a {@link SavedObjectsFindOptionsReference | reference } * to be used to search using the savedObjects `_find` API. Will return `undefined` - * is the given name does not match any existing tag. + * if the given name does not match any existing tag. */ convertNameToReference(tagName: string): SavedObjectsFindOptionsReference | undefined; @@ -124,6 +124,12 @@ export interface SavedObjectsTaggingApiUi { references: Array ): string[]; + /** + * Returns the id for given tag name. Will return `undefined` + * if the given name does not match any existing tag. + */ + getTagIdFromName(tagName: string): string | undefined; + /** * Returns a new references array that replace the old tag references with references to the * new given tag ids, while preserving all non-tag references. diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index 950ecebeaadc74..9f98d9c21d233f 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -41,5 +41,7 @@ export { import { SharePlugin } from './plugin'; export { KibanaURL } from './kibana_url'; +export { downloadMultipleAs, downloadFileAs } from './lib/download_as'; +export type { DownloadableContent } from './lib/download_as'; export const plugin = () => new SharePlugin(); diff --git a/src/plugins/share/public/lib/download_as.ts b/src/plugins/share/public/lib/download_as.ts new file mode 100644 index 00000000000000..6f40b894f85bc6 --- /dev/null +++ b/src/plugins/share/public/lib/download_as.ts @@ -0,0 +1,67 @@ +/* + * 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. + */ + +// @ts-ignore +import { saveAs } from '@elastic/filesaver'; +import pMap from 'p-map'; + +export type DownloadableContent = { content: string; type: string } | Blob; + +/** + * Convenient method to use for a single file download + * **Note**: for multiple files use the downloadMultipleAs method, do not iterate with this method here + * @param filename full name of the file + * @param payload either a Blob content, or a Record with a stringified content and type + * + * @returns a Promise that resolves when the download has been correctly started + */ +export function downloadFileAs(filename: string, payload: DownloadableContent) { + return downloadMultipleAs({ [filename]: payload }); +} + +/** + * Multiple files download method + * @param files a Record containing one entry per file: the key entry should be the filename + * and the value either a Blob content, or a Record with a stringified content and type + * + * @returns a Promise that resolves when all the downloads have been correctly started + */ +export async function downloadMultipleAs(files: Record) { + const filenames = Object.keys(files); + const downloadQueue = filenames.map((filename, i) => { + const payload = files[filename]; + const blob = + // probably this is enough? It does not support Node or custom implementations + payload instanceof Blob ? payload : new Blob([payload.content], { type: payload.type }); + + // TODO: remove this workaround for multiple files when fixed (in filesaver?) + return () => Promise.resolve().then(() => saveAs(blob, filename)); + }); + + // There's a bug in some browser with multiple files downloaded at once + // * sometimes only the first/last content is downloaded multiple times + // * sometimes only the first/last filename is used multiple times + await pMap(downloadQueue, (downloadFn) => Promise.all([downloadFn(), wait(50)]), { + concurrency: 1, + }); +} +// Probably there's already another one around? +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/plugins/vis_type_metric/public/metric_vis_type.ts b/src/plugins/vis_type_metric/public/metric_vis_type.ts index 1c5afd396c2c31..f7c74e324053e6 100644 --- a/src/plugins/vis_type_metric/public/metric_vis_type.ts +++ b/src/plugins/vis_type_metric/public/metric_vis_type.ts @@ -30,7 +30,7 @@ export const createMetricVisTypeDefinition = (): BaseVisTypeOptions => ({ title: i18n.translate('visTypeMetric.metricTitle', { defaultMessage: 'Metric' }), icon: 'visMetric', description: i18n.translate('visTypeMetric.metricDescription', { - defaultMessage: 'Display a calculation as a single number', + defaultMessage: 'Show a calculation as a single number.', }), toExpressionAst, visConfig: { diff --git a/src/plugins/vis_type_table/public/table_vis_type.ts b/src/plugins/vis_type_table/public/table_vis_type.ts index bfc7abac02895a..8546886e8350ea 100644 --- a/src/plugins/vis_type_table/public/table_vis_type.ts +++ b/src/plugins/vis_type_table/public/table_vis_type.ts @@ -29,11 +29,11 @@ import { TableVisParams } from './types'; export const tableVisTypeDefinition: BaseVisTypeOptions = { name: 'table', title: i18n.translate('visTypeTable.tableVisTitle', { - defaultMessage: 'Data Table', + defaultMessage: 'Data table', }), icon: 'visTable', description: i18n.translate('visTypeTable.tableVisDescription', { - defaultMessage: 'Display values in a table', + defaultMessage: 'Display data in rows and columns.', }), getSupportedTriggers: () => { return [VIS_EVENT_TO_TRIGGER.filter]; diff --git a/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts b/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts index d4c37649f949d5..71d4408ddc7676 100644 --- a/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts +++ b/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts @@ -27,13 +27,13 @@ import { toExpressionAst } from './to_ast'; export const tagCloudVisTypeDefinition = { name: 'tagcloud', - title: i18n.translate('visTypeTagCloud.vis.tagCloudTitle', { defaultMessage: 'Tag Cloud' }), + title: i18n.translate('visTypeTagCloud.vis.tagCloudTitle', { defaultMessage: 'Tag cloud' }), icon: 'visTagCloud', getSupportedTriggers: () => { return [VIS_EVENT_TO_TRIGGER.filter]; }, description: i18n.translate('visTypeTagCloud.vis.tagCloudDescription', { - defaultMessage: 'A group of words, sized according to their importance', + defaultMessage: 'Display word frequency with font size.', }), visConfig: { defaults: { diff --git a/src/plugins/vis_type_tagcloud/public/to_ast.ts b/src/plugins/vis_type_tagcloud/public/to_ast.ts index 876784cc101405..69b55b58982578 100644 --- a/src/plugins/vis_type_tagcloud/public/to_ast.ts +++ b/src/plugins/vis_type_tagcloud/public/to_ast.ts @@ -44,9 +44,14 @@ export const toExpressionAst = (vis: Vis, params: BuildPipeli }); const schemas = getVisSchemas(vis, params); + const { scale, orientation, minFontSize, maxFontSize, showLabel } = vis.params; const tagcloud = buildExpressionFunction('tagcloud', { - ...vis.params, + scale, + orientation, + minFontSize, + maxFontSize, + showLabel, metric: prepareDimension(schemas.metric[0]), }); diff --git a/src/plugins/vis_type_timelion/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_timelion/public/__snapshots__/to_ast.test.ts.snap index 9e32a6c4ae17cb..7635e5214795ad 100644 --- a/src/plugins/vis_type_timelion/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_type_timelion/public/__snapshots__/to_ast.test.ts.snap @@ -19,3 +19,23 @@ Object { "type": "expression", } `; + +exports[`timelion vis toExpressionAst function should not escape single quotes 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "expression": Array [ + ".es(index=my*,timefield=\\"date\\",split='test field:3',metric='avg:value')", + ], + "interval": Array [ + "auto", + ], + }, + "function": "timelion_vis", + "type": "function", + }, + ], + "type": "expression", +} +`; diff --git a/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx b/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx index a5425478e46acf..5512fdccd5e7e3 100644 --- a/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx +++ b/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx @@ -42,7 +42,7 @@ export function getTimelionVisDefinition(dependencies: TimelionVisDependencies) title: 'Timelion', icon: 'visTimelion', description: i18n.translate('timelion.timelionDescription', { - defaultMessage: 'Build time-series using functional expressions', + defaultMessage: 'Show time series data on a graph.', }), visConfig: { defaults: { diff --git a/src/plugins/vis_type_timelion/public/to_ast.test.ts b/src/plugins/vis_type_timelion/public/to_ast.test.ts index 8a9d4b83f94d20..f2030e4b83c197 100644 --- a/src/plugins/vis_type_timelion/public/to_ast.test.ts +++ b/src/plugins/vis_type_timelion/public/to_ast.test.ts @@ -37,4 +37,10 @@ describe('timelion vis toExpressionAst function', () => { const actual = toExpressionAst(vis); expect(actual).toMatchSnapshot(); }); + + it('should not escape single quotes', () => { + vis.params.expression = `.es(index=my*,timefield="date",split='test field:3',metric='avg:value')`; + const actual = toExpressionAst(vis); + expect(actual).toMatchSnapshot(); + }); }); diff --git a/src/plugins/vis_type_timelion/public/to_ast.ts b/src/plugins/vis_type_timelion/public/to_ast.ts index 7044bbf4e58318..535e8e8fe0f77e 100644 --- a/src/plugins/vis_type_timelion/public/to_ast.ts +++ b/src/plugins/vis_type_timelion/public/to_ast.ts @@ -21,14 +21,12 @@ import { buildExpression, buildExpressionFunction } from '../../expressions/publ import { Vis } from '../../visualizations/public'; import { TimelionExpressionFunctionDefinition, TimelionVisParams } from './timelion_vis_fn'; -const escapeString = (data: string): string => { - return data.replace(/\\/g, `\\\\`).replace(/'/g, `\\'`); -}; - export const toExpressionAst = (vis: Vis) => { + const { expression, interval } = vis.params; + const timelion = buildExpressionFunction('timelion_vis', { - expression: escapeString(vis.params.expression), - interval: escapeString(vis.params.interval), + expression, + interval, }); const ast = buildExpression([timelion]); diff --git a/src/plugins/vis_type_timeseries/common/constants.ts b/src/plugins/vis_type_timeseries/common/constants.ts index 4f24bc273e265b..bfcb5e8e15b9d0 100644 --- a/src/plugins/vis_type_timeseries/common/constants.ts +++ b/src/plugins/vis_type_timeseries/common/constants.ts @@ -19,7 +19,7 @@ export const MAX_BUCKETS_SETTING = 'metrics:max_buckets'; export const INDEXES_SEPARATOR = ','; - +export const AUTO_INTERVAL = 'auto'; export const ROUTES = { VIS_DATA: '/api/metrics/vis/data', }; diff --git a/src/plugins/vis_type_timeseries/common/vis_schema.ts b/src/plugins/vis_type_timeseries/common/vis_schema.ts index 7f17a9c44298a3..a90fa752ad7dc7 100644 --- a/src/plugins/vis_type_timeseries/common/vis_schema.ts +++ b/src/plugins/vis_type_timeseries/common/vis_schema.ts @@ -175,6 +175,7 @@ export const seriesItems = schema.object({ separate_axis: numberIntegerOptional, seperate_axis: numberIntegerOptional, series_index_pattern: stringOptionalNullable, + series_max_bars: numberIntegerOptional, series_time_field: stringOptionalNullable, series_interval: stringOptionalNullable, series_drop_last_bucket: numberIntegerOptional, @@ -229,6 +230,7 @@ export const panel = schema.object({ ignore_global_filters: numberOptional, ignore_global_filter: numberOptional, index_pattern: stringRequired, + max_bars: numberIntegerOptional, interval: stringRequired, isModelInvalid: schema.maybe(schema.boolean()), legend_position: stringOptionalNullable, diff --git a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js index 85f31285df69bb..e976519dfe635e 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js +++ b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js @@ -19,7 +19,7 @@ import { get } from 'lodash'; import PropTypes from 'prop-types'; -import React, { useContext } from 'react'; +import React, { useContext, useCallback } from 'react'; import { htmlIdGenerator, EuiFieldText, @@ -27,7 +27,10 @@ import { EuiFlexItem, EuiFormRow, EuiComboBox, + EuiRange, + EuiIconTip, EuiText, + EuiFormLabel, } from '@elastic/eui'; import { FieldSelect } from './aggs/field_select'; import { createSelectHandler } from './lib/create_select_handler'; @@ -35,19 +38,20 @@ import { createTextHandler } from './lib/create_text_handler'; import { YesNo } from './yes_no'; import { KBN_FIELD_TYPES } from '../../../../../plugins/data/public'; import { FormValidationContext } from '../contexts/form_validation_context'; -import { - isGteInterval, - validateReInterval, - isAutoInterval, - AUTO_INTERVAL, -} from './lib/get_interval'; +import { isGteInterval, validateReInterval, isAutoInterval } from './lib/get_interval'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { TIME_RANGE_DATA_MODES, TIME_RANGE_MODE_KEY } from '../../../common/timerange_data_modes'; import { PANEL_TYPES } from '../../../common/panel_types'; import { isTimerangeModeEnabled } from '../lib/check_ui_restrictions'; import { VisDataContext } from '../contexts/vis_data_context'; +import { getUISettings } from '../../services'; +import { AUTO_INTERVAL } from '../../../common/constants'; +import { UI_SETTINGS } from '../../../../data/common'; const RESTRICT_FIELDS = [KBN_FIELD_TYPES.DATE]; +const LEVEL_OF_DETAIL_STEPS = 10; +const LEVEL_OF_DETAIL_MIN_BUCKETS = 1; const validateIntervalValue = (intervalValue) => { const isAutoOrGteInterval = isGteInterval(intervalValue) || isAutoInterval(intervalValue); @@ -65,15 +69,36 @@ const htmlId = htmlIdGenerator(); const isEntireTimeRangeActive = (model, isTimeSeries) => !isTimeSeries && model[TIME_RANGE_MODE_KEY] === TIME_RANGE_DATA_MODES.ENTIRE_TIME_RANGE; -export const IndexPattern = ({ fields, prefix, onChange, disabled, model: _model }) => { +export const IndexPattern = ({ + fields, + prefix, + onChange, + disabled, + model: _model, + allowLevelofDetail, +}) => { + const config = getUISettings(); + const handleSelectChange = createSelectHandler(onChange); const handleTextChange = createTextHandler(onChange); + const timeFieldName = `${prefix}time_field`; const indexPatternName = `${prefix}index_pattern`; const intervalName = `${prefix}interval`; + const maxBarsName = `${prefix}max_bars`; const dropBucketName = `${prefix}drop_last_bucket`; const updateControlValidity = useContext(FormValidationContext); const uiRestrictions = get(useContext(VisDataContext), 'uiRestrictions'); + const maxBarsUiSettings = config.get(UI_SETTINGS.HISTOGRAM_MAX_BARS); + + const handleMaxBarsChange = useCallback( + ({ target }) => { + onChange({ + [maxBarsName]: Math.max(LEVEL_OF_DETAIL_MIN_BUCKETS, target.value), + }); + }, + [onChange, maxBarsName] + ); const timeRangeOptions = [ { @@ -97,10 +122,12 @@ export const IndexPattern = ({ fields, prefix, onChange, disabled, model: _model [indexPatternName]: '*', [intervalName]: AUTO_INTERVAL, [dropBucketName]: 1, + [maxBarsName]: config.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), [TIME_RANGE_MODE_KEY]: timeRangeOptions[0].value, }; const model = { ...defaults, ..._model }; + const isDefaultIndexPatternUsed = model.default_index_pattern && !model[indexPatternName]; const intervalValidation = validateIntervalValue(model[intervalName]); const selectedTimeRangeOption = timeRangeOptions.find( @@ -229,6 +256,77 @@ export const IndexPattern = ({ fields, prefix, onChange, disabled, model: _model + {allowLevelofDetail && ( + + + + {' '} + + } + type="questionInCircle" + /> + + } + > + + + + + + + + + + + + + + + + + + + )} ); }; @@ -245,4 +343,5 @@ IndexPattern.propTypes = { prefix: PropTypes.string, disabled: PropTypes.bool, className: PropTypes.string, + allowLevelofDetail: PropTypes.bool, }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_interval.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_interval.js index c1d484765f4cbf..f54d52620e67a1 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/get_interval.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_interval.js @@ -22,8 +22,7 @@ import { get } from 'lodash'; import { search } from '../../../../../../plugins/data/public'; const { parseEsInterval } = search.aggs; import { GTE_INTERVAL_RE } from '../../../../common/interval_regexp'; - -export const AUTO_INTERVAL = 'auto'; +import { AUTO_INTERVAL } from '../../../../common/constants'; export const unitLookup = { s: i18n.translate('visTypeTimeseries.getInterval.secondsLabel', { defaultMessage: 'seconds' }), diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js index 03da52b10f08b5..180411dd13a3d0 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js @@ -193,6 +193,7 @@ class TimeseriesPanelConfigUi extends Component { fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowLevelofDetail={true} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js index 9742d817f7c0d2..7893d5ba6d15e5 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js @@ -26,8 +26,8 @@ import { convertIntervalIntoUnit, isAutoInterval, isGteInterval, - AUTO_INTERVAL, } from './lib/get_interval'; +import { AUTO_INTERVAL } from '../../../common/constants'; import { PANEL_TYPES } from '../../../common/panel_types'; const MIN_CHART_HEIGHT = 300; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js index 59277257c0c942..25561cfe1dc040 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js @@ -554,7 +554,7 @@ export const TimeseriesConfig = injectI18n(function (props) { {...props} prefix="series_" disabled={!model.override_index_pattern} - with-interval={true} + allowLevelofDetail={true} /> diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.js index d11e9316c959b9..1b2334c7dea942 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.js @@ -19,6 +19,7 @@ import { buildAnnotationRequest } from './build_request_body'; import { getEsShardTimeout } from '../helpers/get_es_shard_timeout'; import { getIndexPatternObject } from '../helpers/get_index_pattern'; +import { UI_SETTINGS } from '../../../../../data/common'; export async function getAnnotationRequestParams( req, @@ -27,6 +28,7 @@ export async function getAnnotationRequestParams( esQueryConfig, capabilities ) { + const uiSettings = req.getUiSettingsService(); const esShardTimeout = await getEsShardTimeout(req); const indexPattern = annotation.index_pattern; const { indexPatternObject, indexPatternString } = await getIndexPatternObject(req, indexPattern); @@ -36,7 +38,11 @@ export async function getAnnotationRequestParams( annotation, esQueryConfig, indexPatternObject, - capabilities + capabilities, + { + maxBarsUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + barTargetUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + } ); return { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js index 82a2ef66cb1c0d..9714b551ea82f6 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js @@ -17,6 +17,8 @@ * under the License. */ +import { AUTO_INTERVAL } from '../../../common/constants'; + const DEFAULT_TIME_FIELD = '@timestamp'; export function getIntervalAndTimefield(panel, series = {}, indexPatternObject) { @@ -26,10 +28,18 @@ export function getIntervalAndTimefield(panel, series = {}, indexPatternObject) (series.override_index_pattern && series.series_time_field) || panel.time_field || getDefaultTimeField(); - const interval = (series.override_index_pattern && series.series_interval) || panel.interval; + + let interval = panel.interval; + let maxBars = panel.max_bars; + + if (series.override_index_pattern) { + interval = series.series_interval; + maxBars = series.series_max_bars; + } return { timeField, - interval, + interval: interval || AUTO_INTERVAL, + maxBars, }; } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js index 3791eb229db5bd..eaaa5a9605b4bc 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js @@ -22,6 +22,7 @@ import { get } from 'lodash'; import { processBucket } from './table/process_bucket'; import { getEsQueryConfig } from './helpers/get_es_query_uisettings'; import { getIndexPatternObject } from './helpers/get_index_pattern'; +import { UI_SETTINGS } from '../../../../data/common'; export async function getTableData(req, panel) { const panelIndexPattern = panel.index_pattern; @@ -39,7 +40,12 @@ export async function getTableData(req, panel) { }; try { - const body = buildRequestBody(req, panel, esQueryConfig, indexPatternObject, capabilities); + const uiSettings = req.getUiSettingsService(); + const body = buildRequestBody(req, panel, esQueryConfig, indexPatternObject, capabilities, { + maxBarsUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + barTargetUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + }); + const [resp] = await searchStrategy.search(req, [ { body, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/calculate_auto.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/calculate_auto.js deleted file mode 100644 index 0c3555adff1a6c..00000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/calculate_auto.js +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import moment from 'moment'; -const d = moment.duration; - -const roundingRules = [ - [d(500, 'ms'), d(100, 'ms')], - [d(5, 'second'), d(1, 'second')], - [d(7.5, 'second'), d(5, 'second')], - [d(15, 'second'), d(10, 'second')], - [d(45, 'second'), d(30, 'second')], - [d(3, 'minute'), d(1, 'minute')], - [d(9, 'minute'), d(5, 'minute')], - [d(20, 'minute'), d(10, 'minute')], - [d(45, 'minute'), d(30, 'minute')], - [d(2, 'hour'), d(1, 'hour')], - [d(6, 'hour'), d(3, 'hour')], - [d(24, 'hour'), d(12, 'hour')], - [d(1, 'week'), d(1, 'd')], - [d(3, 'week'), d(1, 'week')], - [d(1, 'year'), d(1, 'month')], - [Infinity, d(1, 'year')], -]; - -const revRoundingRules = roundingRules.slice(0).reverse(); - -function find(rules, check, last) { - function pick(buckets, duration) { - const target = duration / buckets; - let lastResp = null; - - for (let i = 0; i < rules.length; i++) { - const rule = rules[i]; - const resp = check(rule[0], rule[1], target); - - if (resp == null) { - if (!last) continue; - if (lastResp) return lastResp; - break; - } - - if (!last) return resp; - lastResp = resp; - } - - // fallback to just a number of milliseconds, ensure ms is >= 1 - const ms = Math.max(Math.floor(target), 1); - return moment.duration(ms, 'ms'); - } - - return (buckets, duration) => { - const interval = pick(buckets, duration); - if (interval) return moment.duration(interval._data); - }; -} - -export const calculateAuto = { - near: find( - revRoundingRules, - function near(bound, interval, target) { - if (bound > target) return interval; - }, - true - ), - - lessThan: find(revRoundingRules, function lessThan(_bound, interval, target) { - if (interval < target) return interval; - }), - - atLeast: find(revRoundingRules, function atLeast(_bound, interval, target) { - if (interval <= target) return interval; - }), -}; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js index c021ba3cebc668..4384da58fb5692 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js @@ -17,15 +17,15 @@ * under the License. */ -import { calculateAuto } from './calculate_auto'; import { getUnitValue, parseInterval, convertIntervalToUnit, ASCENDING_UNIT_ORDER, } from './unit_to_seconds'; -import { getTimerangeDuration } from './get_timerange'; +import { getTimerange } from './get_timerange'; import { INTERVAL_STRING_RE, GTE_INTERVAL_RE } from '../../../../common/interval_regexp'; +import { search } from '../../../../../data/server'; const calculateBucketData = (timeInterval, capabilities) => { let intervalString = capabilities @@ -65,14 +65,15 @@ const calculateBucketData = (timeInterval, capabilities) => { }; }; -const calculateBucketSizeForAutoInterval = (req) => { - const duration = getTimerangeDuration(req); +const calculateBucketSizeForAutoInterval = (req, maxBars) => { + const { from, to } = getTimerange(req); + const timerange = to.valueOf() - from.valueOf(); - return calculateAuto.near(100, duration).asSeconds(); + return search.aggs.calcAutoIntervalLessThan(maxBars, timerange).asSeconds(); }; -export const getBucketSize = (req, interval, capabilities) => { - const bucketSize = calculateBucketSizeForAutoInterval(req); +export const getBucketSize = (req, interval, capabilities, maxBars) => { + const bucketSize = calculateBucketSizeForAutoInterval(req, maxBars); let intervalString = `${bucketSize}s`; const gteAutoMatch = Boolean(interval) && interval.match(GTE_INTERVAL_RE); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.js index 99bef2de6b72d3..8810ccd406be40 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.js @@ -30,37 +30,43 @@ describe('getBucketSize', () => { }; test('returns auto calculated buckets', () => { - const result = getBucketSize(req, 'auto'); + const result = getBucketSize(req, 'auto', undefined, 100); + expect(result).toHaveProperty('bucketSize', 30); expect(result).toHaveProperty('intervalString', '30s'); }); test('returns overridden buckets (1s)', () => { - const result = getBucketSize(req, '1s'); + const result = getBucketSize(req, '1s', undefined, 100); + expect(result).toHaveProperty('bucketSize', 1); expect(result).toHaveProperty('intervalString', '1s'); }); test('returns overridden buckets (10m)', () => { - const result = getBucketSize(req, '10m'); + const result = getBucketSize(req, '10m', undefined, 100); + expect(result).toHaveProperty('bucketSize', 600); expect(result).toHaveProperty('intervalString', '10m'); }); test('returns overridden buckets (1d)', () => { - const result = getBucketSize(req, '1d'); + const result = getBucketSize(req, '1d', undefined, 100); + expect(result).toHaveProperty('bucketSize', 86400); expect(result).toHaveProperty('intervalString', '1d'); }); test('returns overridden buckets (>=2d)', () => { - const result = getBucketSize(req, '>=2d'); + const result = getBucketSize(req, '>=2d', undefined, 100); + expect(result).toHaveProperty('bucketSize', 86400 * 2); expect(result).toHaveProperty('intervalString', '2d'); }); test('returns overridden buckets (>=10s)', () => { - const result = getBucketSize(req, '>=10s'); + const result = getBucketSize(req, '>=10s', undefined, 100); + expect(result).toHaveProperty('bucketSize', 30); expect(result).toHaveProperty('intervalString', '30s'); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts similarity index 92% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.js rename to src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts index 1a1b12c651992f..183ce50dd4a093 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts @@ -17,20 +17,22 @@ * under the License. */ -import { getTimerange } from './get_timerange'; import moment from 'moment'; +import { getTimerange } from './get_timerange'; +import { ReqFacade, VisPayload } from '../../..'; describe('getTimerange(req)', () => { test('should return a moment object for to and from', () => { - const req = { + const req = ({ payload: { timerange: { min: '2017-01-01T00:00:00Z', max: '2017-01-01T01:00:00Z', }, }, - }; + } as unknown) as ReqFacade; const { from, to } = getTimerange(req); + expect(moment.isMoment(from)).toEqual(true); expect(moment.isMoment(to)).toEqual(true); expect(moment.utc('2017-01-01T00:00:00Z').isSame(from)).toEqual(true); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts similarity index 75% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.js rename to src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts index 682befe9ab050e..54f3110b45808f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts @@ -17,19 +17,14 @@ * under the License. */ -import moment from 'moment'; +import { utc } from 'moment'; +import { ReqFacade, VisPayload } from '../../..'; -export const getTimerange = (req) => { +export const getTimerange = (req: ReqFacade) => { const { min, max } = req.payload.timerange; return { - from: moment.utc(min), - to: moment.utc(max), + from: utc(min), + to: utc(max), }; }; - -export const getTimerangeDuration = (req) => { - const { from, to } = getTimerange(req); - - return moment.duration(to.valueOf() - from.valueOf(), 'ms'); -}; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js index 4b611e46f15884..617a75f6bd59f8 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js @@ -29,11 +29,17 @@ export function dateHistogram( annotation, esQueryConfig, indexPatternObject, - capabilities + capabilities, + { barTargetUiSettings } ) { return (next) => (doc) => { const timeField = annotation.time_field; - const { bucketSize, intervalString } = getBucketSize(req, 'auto', capabilities); + const { bucketSize, intervalString } = getBucketSize( + req, + 'auto', + capabilities, + barTargetUiSettings + ); const { from, to } = getTimerange(req); const timezone = capabilities.searchTimezone; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js index 127687bf11fe9f..cf02f601ea5ffb 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js @@ -21,10 +21,18 @@ import { getBucketSize } from '../../helpers/get_bucket_size'; import { getTimerange } from '../../helpers/get_timerange'; import { esQuery } from '../../../../../../data/server'; -export function query(req, panel, annotation, esQueryConfig, indexPattern, capabilities) { +export function query( + req, + panel, + annotation, + esQueryConfig, + indexPattern, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const timeField = annotation.time_field; - const { bucketSize } = getBucketSize(req, 'auto', capabilities); + const { bucketSize } = getBucketSize(req, 'auto', capabilities, barTargetUiSettings); const { from, to } = getTimerange(req); doc.size = 0; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index f1e58b8e4af2ab..98c683bda1fdbf 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -25,10 +25,27 @@ import { isLastValueTimerangeMode } from '../../helpers/get_timerange_mode'; import { search } from '../../../../../../../plugins/data/server'; const { dateHistogramInterval } = search.aggs; -export function dateHistogram(req, panel, series, esQueryConfig, indexPatternObject, capabilities) { +export function dateHistogram( + req, + panel, + series, + esQueryConfig, + indexPatternObject, + capabilities, + { maxBarsUiSettings, barTargetUiSettings } +) { return (next) => (doc) => { - const { timeField, interval } = getIntervalAndTimefield(panel, series, indexPatternObject); - const { bucketSize, intervalString } = getBucketSize(req, interval, capabilities); + const { timeField, interval, maxBars } = getIntervalAndTimefield( + panel, + series, + indexPatternObject + ); + const { bucketSize, intervalString } = getBucketSize( + req, + interval, + capabilities, + maxBars ? Math.min(maxBarsUiSettings, maxBars) : barTargetUiSettings + ); const getDateHistogramForLastBucketMode = () => { const { from, to } = offsetTime(req, series.offset_time); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js index 45cad1195fc78d..aa95a79a627965 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js @@ -27,6 +27,7 @@ describe('dateHistogram(req, panel, series)', () => { let capabilities; let config; let indexPatternObject; + let uiSettings; beforeEach(() => { req = { @@ -50,19 +51,29 @@ describe('dateHistogram(req, panel, series)', () => { }; indexPatternObject = {}; capabilities = new DefaultSearchCapabilities(req); + uiSettings = { maxBarsUiSettings: 100, barTargetUiSettings: 50 }; }); test('calls next when finished', () => { const next = jest.fn(); - dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)({}); + dateHistogram(req, panel, series, config, indexPatternObject, capabilities, uiSettings)(next)( + {} + ); expect(next.mock.calls.length).toEqual(1); }); test('returns valid date histogram', () => { const next = (doc) => doc; - const doc = dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)( - {} - ); + const doc = dateHistogram( + req, + panel, + series, + config, + indexPatternObject, + capabilities, + uiSettings + )(next)({}); + expect(doc).toEqual({ aggs: { test: { @@ -94,9 +105,16 @@ describe('dateHistogram(req, panel, series)', () => { test('returns valid date histogram (offset by 1h)', () => { series.offset_time = '1h'; const next = (doc) => doc; - const doc = dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)( - {} - ); + const doc = dateHistogram( + req, + panel, + series, + config, + indexPatternObject, + capabilities, + uiSettings + )(next)({}); + expect(doc).toEqual({ aggs: { test: { @@ -131,9 +149,16 @@ describe('dateHistogram(req, panel, series)', () => { series.series_time_field = 'timestamp'; series.series_interval = '20s'; const next = (doc) => doc; - const doc = dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)( - {} - ); + const doc = dateHistogram( + req, + panel, + series, + config, + indexPatternObject, + capabilities, + uiSettings + )(next)({}); + expect(doc).toEqual({ aggs: { test: { @@ -168,9 +193,15 @@ describe('dateHistogram(req, panel, series)', () => { panel.type = 'timeseries'; const next = (doc) => doc; - const doc = dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)( - {} - ); + const doc = dateHistogram( + req, + panel, + series, + config, + indexPatternObject, + capabilities, + uiSettings + )(next)({}); expect(doc.aggs.test.aggs.timeseries.auto_date_histogram).toBeUndefined(); expect(doc.aggs.test.aggs.timeseries.date_histogram).toBeDefined(); @@ -180,9 +211,16 @@ describe('dateHistogram(req, panel, series)', () => { panel.time_range_mode = 'entire_time_range'; const next = (doc) => doc; - const doc = dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)( - {} - ); + const doc = dateHistogram( + req, + panel, + series, + config, + indexPatternObject, + capabilities, + uiSettings + )(next)({}); + expect(doc).toEqual({ aggs: { test: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js index 800145dac54680..023ee054a5e133 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -21,10 +21,19 @@ import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; -export function metricBuckets(req, panel, series, esQueryConfig, indexPatternObject, capabilities) { +export function metricBuckets( + req, + panel, + series, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); - const { intervalString } = getBucketSize(req, interval, capabilities); + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + series.metrics .filter((row) => !/_bucket$/.test(row.type) && !/^series/.test(row.type)) .forEach((metric) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.test.js index 1ac4329b60f82d..2154d2257815b2 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.test.js @@ -20,56 +20,64 @@ import { metricBuckets } from './metric_buckets'; describe('metricBuckets(req, panel, series)', () => { - let panel; - let series; - let req; + let metricBucketsProcessor; + beforeEach(() => { - panel = { - time_field: 'timestamp', - }; - series = { - id: 'test', - split_mode: 'terms', - terms_size: 10, - terms_field: 'host', - metrics: [ - { - id: 'metric-1', - type: 'max', - field: 'io', - }, - { - id: 'metric-2', - type: 'derivative', - field: 'metric-1', - unit: '1s', - }, - { - id: 'metric-3', - type: 'avg_bucket', - field: 'metric-2', - }, - ], - }; - req = { - payload: { - timerange: { - min: '2017-01-01T00:00:00Z', - max: '2017-01-01T01:00:00Z', + metricBucketsProcessor = metricBuckets( + { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z', + }, }, }, - }; + { + time_field: 'timestamp', + }, + { + id: 'test', + split_mode: 'terms', + terms_size: 10, + terms_field: 'host', + metrics: [ + { + id: 'metric-1', + type: 'max', + field: 'io', + }, + { + id: 'metric-2', + type: 'derivative', + field: 'metric-1', + unit: '1s', + }, + { + id: 'metric-3', + type: 'avg_bucket', + field: 'metric-2', + }, + ], + }, + {}, + {}, + undefined, + { + barTargetUiSettings: 50, + } + ); }); test('calls next when finished', () => { const next = jest.fn(); - metricBuckets(req, panel, series)(next)({}); + metricBucketsProcessor(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns metric aggs', () => { const next = (doc) => doc; - const doc = metricBuckets(req, panel, series)(next)({}); + const doc = metricBucketsProcessor(next)({}); + expect(doc).toEqual({ aggs: { test: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js index 4a79ec22958779..c16e0fd3aaf158 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js @@ -57,10 +57,19 @@ export const createPositiveRate = (doc, intervalString, aggRoot) => (metric) => overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, positiveOnlyBucket); }; -export function positiveRate(req, panel, series, esQueryConfig, indexPatternObject, capabilities) { +export function positiveRate( + req, + panel, + series, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); - const { intervalString } = getBucketSize(req, interval, capabilities); + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + if (series.metrics.some(filter)) { series.metrics .filter(filter) diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.test.js index 7c0f43adf02f55..d891fc01bb266d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.test.js @@ -22,6 +22,8 @@ describe('positiveRate(req, panel, series)', () => { let panel; let series; let req; + let uiSettings; + beforeEach(() => { panel = { time_field: 'timestamp', @@ -48,17 +50,20 @@ describe('positiveRate(req, panel, series)', () => { }, }, }; + uiSettings = { + barTargetUiSettings: 50, + }; }); test('calls next when finished', () => { const next = jest.fn(); - positiveRate(req, panel, series)(next)({}); + positiveRate(req, panel, series, {}, {}, undefined, uiSettings)(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns positive rate aggs', () => { const next = (doc) => doc; - const doc = positiveRate(req, panel, series)(next)({}); + const doc = positiveRate(req, panel, series, {}, {}, undefined, uiSettings)(next)({}); expect(doc).toEqual({ aggs: { test: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js index f2b58822e68b68..f69473b613d1b3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -28,11 +28,13 @@ export function siblingBuckets( series, esQueryConfig, indexPatternObject, - capabilities + capabilities, + { barTargetUiSettings } ) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); - const { bucketSize } = getBucketSize(req, interval, capabilities); + const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + series.metrics .filter((row) => /_bucket$/.test(row.type)) .forEach((metric) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.test.js index 8f84023ce0c75b..48714e83341ea0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.test.js @@ -23,6 +23,8 @@ describe('siblingBuckets(req, panel, series)', () => { let panel; let series; let req; + let uiSettings; + beforeEach(() => { panel = { time_field: 'timestamp', @@ -53,17 +55,21 @@ describe('siblingBuckets(req, panel, series)', () => { }, }, }; + uiSettings = { + barTargetUiSettings: 50, + }; }); test('calls next when finished', () => { const next = jest.fn(); - siblingBuckets(req, panel, series)(next)({}); + siblingBuckets(req, panel, series, {}, {}, undefined, uiSettings)(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns sibling aggs', () => { const next = (doc) => doc; - const doc = siblingBuckets(req, panel, series)(next)({}); + const doc = siblingBuckets(req, panel, series, {}, {}, undefined, uiSettings)(next)({}); + expect(doc).toEqual({ aggs: { test: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 947e48ed2cab22..ba65e583cc0940 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -26,7 +26,14 @@ import { calculateAggRoot } from './calculate_agg_root'; import { search } from '../../../../../../../plugins/data/server'; const { dateHistogramInterval } = search.aggs; -export function dateHistogram(req, panel, esQueryConfig, indexPatternObject, capabilities) { +export function dateHistogram( + req, + panel, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { timeField, interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); const meta = { @@ -34,7 +41,12 @@ export function dateHistogram(req, panel, esQueryConfig, indexPatternObject, cap }; const getDateHistogramForLastBucketMode = () => { - const { bucketSize, intervalString } = getBucketSize(req, interval, capabilities); + const { bucketSize, intervalString } = getBucketSize( + req, + interval, + capabilities, + barTargetUiSettings + ); const { from, to } = getTimerange(req); const timezone = capabilities.searchTimezone; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js index ba2c09e93e7e6b..fe6a8b537d64b4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js @@ -23,10 +23,18 @@ import { bucketTransform } from '../../helpers/bucket_transform'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -export function metricBuckets(req, panel, esQueryConfig, indexPatternObject) { +export function metricBuckets( + req, + panel, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); - const { intervalString } = getBucketSize(req, interval); + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js index b219f84deef803..6cf165d124e264 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js @@ -22,10 +22,18 @@ import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; import { createPositiveRate, filter } from '../series/positive_rate'; -export function positiveRate(req, panel, esQueryConfig, indexPatternObject) { +export function positiveRate( + req, + panel, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); - const { intervalString } = getBucketSize(req, interval); + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics.filter(filter).forEach(createPositiveRate(doc, intervalString, aggRoot)); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js index 1b14ffe34a9472..ba08b18256dec5 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js @@ -23,10 +23,18 @@ import { bucketTransform } from '../../helpers/bucket_transform'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -export function siblingBuckets(req, panel, esQueryConfig, indexPatternObject) { +export function siblingBuckets( + req, + panel, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); - const { bucketSize } = getBucketSize(req, interval); + const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts index 0c75e6ef1c5bd5..6b2ef320d54b7b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts @@ -97,7 +97,8 @@ describe('buildRequestBody(req)', () => { series, config, indexPatternObject, - capabilities + capabilities, + { barTargetUiSettings: 50 } ); expect(doc).toEqual({ diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.js index 4c653ea49e7c6d..3804b1407b0865 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.js @@ -19,18 +19,25 @@ import { buildRequestBody } from './build_request_body'; import { getEsShardTimeout } from '../helpers/get_es_shard_timeout'; import { getIndexPatternObject } from '../helpers/get_index_pattern'; +import { UI_SETTINGS } from '../../../../../data/common'; export async function getSeriesRequestParams(req, panel, series, esQueryConfig, capabilities) { + const uiSettings = req.getUiSettingsService(); const indexPattern = (series.override_index_pattern && series.series_index_pattern) || panel.index_pattern; const { indexPatternObject, indexPatternString } = await getIndexPatternObject(req, indexPattern); + const request = buildRequestBody( req, panel, series, esQueryConfig, indexPatternObject, - capabilities + capabilities, + { + maxBarsUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + barTargetUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + } ); const esShardTimeout = await getEsShardTimeout(req); diff --git a/src/plugins/vis_type_vislib/public/area.ts b/src/plugins/vis_type_vislib/public/area.ts index 531958d6b3db3a..ec7bce254f586b 100644 --- a/src/plugins/vis_type_vislib/public/area.ts +++ b/src/plugins/vis_type_vislib/public/area.ts @@ -47,7 +47,7 @@ export const areaVisTypeDefinition: BaseVisTypeOptions = { title: i18n.translate('visTypeVislib.area.areaTitle', { defaultMessage: 'Area' }), icon: 'visArea', description: i18n.translate('visTypeVislib.area.areaDescription', { - defaultMessage: 'Emphasize the quantity beneath a line chart', + defaultMessage: 'Emphasize the data between an axis and a line.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush], toExpressionAst, diff --git a/src/plugins/vis_type_vislib/public/gauge.ts b/src/plugins/vis_type_vislib/public/gauge.ts index 2b3c415087ee1e..bd3bdd1a01e9d3 100644 --- a/src/plugins/vis_type_vislib/public/gauge.ts +++ b/src/plugins/vis_type_vislib/public/gauge.ts @@ -61,7 +61,7 @@ export const gaugeVisTypeDefinition: BaseVisTypeOptions = { title: i18n.translate('visTypeVislib.gauge.gaugeTitle', { defaultMessage: 'Gauge' }), icon: 'visGauge', description: i18n.translate('visTypeVislib.gauge.gaugeDescription', { - defaultMessage: 'Gauges indicate the status of a metric.', + defaultMessage: 'Show the status of a metric.', }), toExpressionAst, visConfig: { diff --git a/src/plugins/vis_type_vislib/public/goal.ts b/src/plugins/vis_type_vislib/public/goal.ts index 32574fb5b0a9cb..46878ca82e45a8 100644 --- a/src/plugins/vis_type_vislib/public/goal.ts +++ b/src/plugins/vis_type_vislib/public/goal.ts @@ -33,7 +33,7 @@ export const goalVisTypeDefinition: BaseVisTypeOptions = { title: i18n.translate('visTypeVislib.goal.goalTitle', { defaultMessage: 'Goal' }), icon: 'visGoal', description: i18n.translate('visTypeVislib.goal.goalDescription', { - defaultMessage: 'A goal chart indicates how close you are to your final goal.', + defaultMessage: 'Track how a metric progresses to a goal.', }), toExpressionAst, visConfig: { diff --git a/src/plugins/vis_type_vislib/public/heatmap.ts b/src/plugins/vis_type_vislib/public/heatmap.ts index f970eddd645f5c..c408ac140dd467 100644 --- a/src/plugins/vis_type_vislib/public/heatmap.ts +++ b/src/plugins/vis_type_vislib/public/heatmap.ts @@ -43,10 +43,10 @@ export interface HeatmapVisParams extends CommonVislibParams, ColorSchemaParams export const heatmapVisTypeDefinition: BaseVisTypeOptions = { name: 'heatmap', - title: i18n.translate('visTypeVislib.heatmap.heatmapTitle', { defaultMessage: 'Heat Map' }), + title: i18n.translate('visTypeVislib.heatmap.heatmapTitle', { defaultMessage: 'Heat map' }), icon: 'heatmap', description: i18n.translate('visTypeVislib.heatmap.heatmapDescription', { - defaultMessage: 'Shade cells within a matrix', + defaultMessage: 'Shade data in cells in a matrix.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], toExpressionAst, diff --git a/src/plugins/vis_type_vislib/public/histogram.ts b/src/plugins/vis_type_vislib/public/histogram.ts index d5fb92f5c6a0c2..de4855ba9aa2b7 100644 --- a/src/plugins/vis_type_vislib/public/histogram.ts +++ b/src/plugins/vis_type_vislib/public/histogram.ts @@ -44,11 +44,11 @@ import { toExpressionAst } from './to_ast'; export const histogramVisTypeDefinition: BaseVisTypeOptions = { name: 'histogram', title: i18n.translate('visTypeVislib.histogram.histogramTitle', { - defaultMessage: 'Vertical Bar', + defaultMessage: 'Vertical bar', }), icon: 'visBarVertical', description: i18n.translate('visTypeVislib.histogram.histogramDescription', { - defaultMessage: 'Assign a continuous variable to each axis', + defaultMessage: 'Present data in vertical bars on an axis.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush], toExpressionAst, diff --git a/src/plugins/vis_type_vislib/public/horizontal_bar.ts b/src/plugins/vis_type_vislib/public/horizontal_bar.ts index f1a5365e5ae743..144e63224533bf 100644 --- a/src/plugins/vis_type_vislib/public/horizontal_bar.ts +++ b/src/plugins/vis_type_vislib/public/horizontal_bar.ts @@ -42,11 +42,11 @@ import { toExpressionAst } from './to_ast'; export const horizontalBarVisTypeDefinition: BaseVisTypeOptions = { name: 'horizontal_bar', title: i18n.translate('visTypeVislib.horizontalBar.horizontalBarTitle', { - defaultMessage: 'Horizontal Bar', + defaultMessage: 'Horizontal bar', }), icon: 'visBarHorizontal', description: i18n.translate('visTypeVislib.horizontalBar.horizontalBarDescription', { - defaultMessage: 'Assign a continuous variable to each axis', + defaultMessage: 'Present data in horizontal bars on an axis.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush], toExpressionAst, diff --git a/src/plugins/vis_type_vislib/public/line.ts b/src/plugins/vis_type_vislib/public/line.ts index a65b0bcf7e2bb7..ffa40c8c299806 100644 --- a/src/plugins/vis_type_vislib/public/line.ts +++ b/src/plugins/vis_type_vislib/public/line.ts @@ -45,7 +45,7 @@ export const lineVisTypeDefinition: BaseVisTypeOptions = { title: i18n.translate('visTypeVislib.line.lineTitle', { defaultMessage: 'Line' }), icon: 'visLine', description: i18n.translate('visTypeVislib.line.lineDescription', { - defaultMessage: 'Emphasize trends', + defaultMessage: 'Display data as a series of points.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush], toExpressionAst, diff --git a/src/plugins/vis_type_vislib/public/pie.ts b/src/plugins/vis_type_vislib/public/pie.ts index 58f7dd0df89e86..41b271054d59f0 100644 --- a/src/plugins/vis_type_vislib/public/pie.ts +++ b/src/plugins/vis_type_vislib/public/pie.ts @@ -43,7 +43,7 @@ export const pieVisTypeDefinition: BaseVisTypeOptions = { title: i18n.translate('visTypeVislib.pie.pieTitle', { defaultMessage: 'Pie' }), icon: 'visPie', description: i18n.translate('visTypeVislib.pie.pieDescription', { - defaultMessage: 'Compare parts of a whole', + defaultMessage: 'Compare data in proportion to a whole.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], toExpressionAst, diff --git a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts index 39a2db12ffad16..7f971d44af9620 100644 --- a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts +++ b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts @@ -199,7 +199,7 @@ describe('useVisualizeAppState', () => { renderHook(() => useVisualizeAppState(mockServices, eventEmitter, savedVisInstance)); - await new Promise((res) => { + await new Promise((res) => { setTimeout(() => res()); }); diff --git a/test/common/services/deployment.ts b/test/common/services/deployment.ts new file mode 100644 index 00000000000000..88389b57dd1db2 --- /dev/null +++ b/test/common/services/deployment.ts @@ -0,0 +1,78 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { get } from 'lodash'; +import fetch from 'node-fetch'; +// @ts-ignore not TS yet +import getUrl from '../../../src/test_utils/get_url'; + +import { FtrProviderContext } from '../ftr_provider_context'; + +export function DeploymentProvider({ getService }: FtrProviderContext) { + const config = getService('config'); + + return { + /** + * Returns Kibana host URL + */ + getHostPort() { + return getUrl.baseUrl(config.get('servers.kibana')); + }, + + /** + * Returns ES host URL + */ + getEsHostPort() { + return getUrl.baseUrl(config.get('servers.elasticsearch')); + }, + + /** + * Helper to detect an OSS licensed Kibana + * Useful for functional testing in cloud environment + */ + async isOss() { + const baseUrl = this.getEsHostPort(); + const username = config.get('servers.elasticsearch.username'); + const password = config.get('servers.elasticsearch.password'); + const response = await fetch(baseUrl + '/_xpack', { + method: 'get', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Basic ' + Buffer.from(username + ':' + password).toString('base64'), + }, + }); + return response.status !== 200; + }, + + async isCloud(): Promise { + const baseUrl = this.getHostPort(); + const username = config.get('servers.kibana.username'); + const password = config.get('servers.kibana.password'); + const response = await fetch(baseUrl + '/api/stats?extended', { + method: 'get', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Basic ' + Buffer.from(username + ':' + password).toString('base64'), + }, + }); + const data = await response.json(); + return get(data, 'usage.cloud.is_cloud_enabled', false); + }, + }; +} diff --git a/test/common/services/index.ts b/test/common/services/index.ts index 0a714e9875c370..b9fa99995ce9d9 100644 --- a/test/common/services/index.ts +++ b/test/common/services/index.ts @@ -17,6 +17,7 @@ * under the License. */ +import { DeploymentProvider } from './deployment'; import { LegacyEsProvider } from './legacy_es'; import { ElasticsearchProvider } from './elasticsearch'; import { EsArchiverProvider } from './es_archiver'; @@ -26,6 +27,7 @@ import { RandomnessProvider } from './randomness'; import { SecurityServiceProvider } from './security'; export const services = { + deployment: DeploymentProvider, legacyEs: LegacyEsProvider, es: ElasticsearchProvider, esArchiver: EsArchiverProvider, diff --git a/test/examples/state_sync/todo_app.ts b/test/examples/state_sync/todo_app.ts index 1ac5376b9ed8d0..d29a533aa1af19 100644 --- a/test/examples/state_sync/todo_app.ts +++ b/test/examples/state_sync/todo_app.ts @@ -29,6 +29,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const browser = getService('browser'); const PageObjects = getPageObjects(['common']); const log = getService('log'); + const deployment = getService('deployment'); describe('TODO app', () => { describe("TODO app with browser history (platform's ScopedHistory)", async () => { @@ -36,7 +37,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide let base: string; before(async () => { - base = await PageObjects.common.getHostPort(); + base = await deployment.getHostPort(); await PageObjects.common.navigateToApp(appId, { insertTimestamp: false }); }); diff --git a/test/functional/apps/dashboard/url_field_formatter.ts b/test/functional/apps/dashboard/url_field_formatter.ts index a18ad740681bfd..62cc1a7e957542 100644 --- a/test/functional/apps/dashboard/url_field_formatter.ts +++ b/test/functional/apps/dashboard/url_field_formatter.ts @@ -34,6 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const browser = getService('browser'); const fieldName = 'clientip'; + const deployment = getService('deployment'); const clickFieldAndCheckUrl = async (fieldLink: WebElementWrapper) => { const fieldValue = await fieldLink.getVisibleText(); @@ -42,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(windowHandlers.length).to.equal(2); await browser.switchToWindow(windowHandlers[1]); const currentUrl = await browser.getCurrentUrl(); - const fieldUrl = common.getHostPort() + '/app/' + fieldValue; + const fieldUrl = deployment.getHostPort() + '/app/' + fieldValue; expect(currentUrl).to.equal(fieldUrl); }; diff --git a/test/functional/apps/discover/_doc_table.ts b/test/functional/apps/discover/_doc_table.ts index dceb12a02f87f4..49b160cc703124 100644 --- a/test/functional/apps/discover/_doc_table.ts +++ b/test/functional/apps/discover/_doc_table.ts @@ -43,14 +43,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // and load a set of makelogs data await esArchiver.loadIfNeeded('logstash_functional'); await kibanaServer.uiSettings.replace(defaultSettings); + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); log.debug('discover doc table'); await PageObjects.common.navigateToApp('discover'); }); - beforeEach(async function () { - await PageObjects.timePicker.setDefaultAbsoluteRange(); - }); - it('should show the first 50 rows by default', async function () { // with the default range the number of hits is ~14000 const rows = await PageObjects.discover.getDocTableRows(); @@ -68,6 +65,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const finalRows = await PageObjects.discover.getDocTableRows(); expect(finalRows.length).to.be.below(initialRows.length); + await PageObjects.timePicker.setDefaultAbsoluteRange(); }); it(`should load up to ${rowsHardLimit} rows when scrolling at the end of the table`, async function () { @@ -89,8 +87,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await footer.getVisibleText()).to.have.string(rowsHardLimit); }); - // FLAKY: https://github.com/elastic/kibana/issues/81632 - describe.skip('expand a document row', function () { + describe('expand a document row', function () { const rowToInspect = 1; beforeEach(async function () { // close the toggle if open diff --git a/test/functional/apps/discover/_shared_links.js b/test/functional/apps/discover/_shared_links.js index 56c64856240436..9cd92626f73bfd 100644 --- a/test/functional/apps/discover/_shared_links.js +++ b/test/functional/apps/discover/_shared_links.js @@ -27,13 +27,14 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['common', 'discover', 'share', 'timePicker']); const browser = getService('browser'); const toasts = getService('toasts'); + const deployment = getService('deployment'); // FLAKY: https://github.com/elastic/kibana/issues/80104 describe.skip('shared links', function describeIndexTests() { let baseUrl; async function setup({ storeStateInSessionStorage }) { - baseUrl = PageObjects.common.getHostPort(); + baseUrl = deployment.getHostPort(); log.debug('baseUrl = ' + baseUrl); // browsers don't show the ':port' if it's 80 or 443 so we have to // remove that part so we can get a match in the tests. diff --git a/test/functional/apps/home/_newsfeed.ts b/test/functional/apps/home/_newsfeed.ts index aabd243e48f21a..4568ba2b47d807 100644 --- a/test/functional/apps/home/_newsfeed.ts +++ b/test/functional/apps/home/_newsfeed.ts @@ -22,7 +22,8 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const globalNav = getService('globalNav'); - const PageObjects = getPageObjects(['common', 'newsfeed']); + const deployment = getService('deployment'); + const PageObjects = getPageObjects(['newsfeed']); describe('Newsfeed', () => { before(async () => { @@ -48,7 +49,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('shows all news from newsfeed', async () => { const objects = await PageObjects.newsfeed.getNewsfeedList(); - if (await PageObjects.common.isOss()) { + if (await deployment.isOss()) { expect(objects).to.eql([ '21 June 2019\nYou are functionally testing the newsfeed widget with fixtures!\nSee test/common/fixtures/plugins/newsfeed/newsfeed_simulation\nGeneric feed-viewer could go here', '21 June 2019\nStaging too!\nHello world\nGeneric feed-viewer could go here', diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 5ca01f239e762f..a2cc976f23127c 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -44,6 +44,7 @@ export default function ({ getService, getPageObjects }) { const inspector = getService('inspector'); const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); + const deployment = getService('deployment'); const PageObjects = getPageObjects([ 'common', 'header', @@ -202,7 +203,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName); await PageObjects.header.waitUntilLoadingHasFinished(); - if (await PageObjects.common.isOss()) { + if (await deployment.isOss()) { // OSS renders a vertical bar chart and we check the data in the Inspect panel const expectedChartValues = [ ['14', '31'], @@ -318,7 +319,7 @@ export default function ({ getService, getPageObjects }) { it('should visualize scripted field in vertical bar chart', async function () { await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName2); await PageObjects.header.waitUntilLoadingHasFinished(); - if (await PageObjects.common.isOss()) { + if (await deployment.isOss()) { // OSS renders a vertical bar chart and we check the data in the Inspect panel await inspector.open(); await inspector.expectTableData([ @@ -414,7 +415,7 @@ export default function ({ getService, getPageObjects }) { it('should visualize scripted field in vertical bar chart', async function () { await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName2); await PageObjects.header.waitUntilLoadingHasFinished(); - if (await PageObjects.common.isOss()) { + if (await deployment.isOss()) { // OSS renders a vertical bar chart and we check the data in the Inspect panel await inspector.open(); await inspector.expectTableData([ @@ -514,7 +515,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName2); await PageObjects.header.waitUntilLoadingHasFinished(); - if (await PageObjects.common.isOss()) { + if (await deployment.isOss()) { // OSS renders a vertical bar chart and we check the data in the Inspect panel await inspector.open(); await inspector.setTablePageSize(50); diff --git a/test/functional/apps/visualize/_chart_types.ts b/test/functional/apps/visualize/_chart_types.ts index 4864fcbf3af095..b404b74039be98 100644 --- a/test/functional/apps/visualize/_chart_types.ts +++ b/test/functional/apps/visualize/_chart_types.ts @@ -22,14 +22,15 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { + const deployment = getService('deployment'); const log = getService('log'); - const PageObjects = getPageObjects(['common', 'visualize']); + const PageObjects = getPageObjects(['visualize']); let isOss = true; describe('chart types', function () { before(async function () { log.debug('navigateToApp visualize'); - isOss = await PageObjects.common.isOss(); + isOss = await deployment.isOss(); await PageObjects.visualize.navigateToNewVisualization(); }); @@ -49,18 +50,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { let expectedChartTypes = [ 'Area', 'Coordinate Map', - 'Data Table', + 'Data table', 'Gauge', 'Goal', - 'Heat Map', - 'Horizontal Bar', + 'Heat map', + 'Horizontal bar', 'Line', 'Metric', 'Pie', 'Region Map', - 'Tag Cloud', + 'Tag cloud', 'Timelion', - 'Vertical Bar', + 'Vertical bar', ]; if (!isOss) { expectedChartTypes = _.remove(expectedChartTypes, function (n) { diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index a30517519820e0..de73b2deabbd92 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -20,12 +20,12 @@ import { FtrProviderContext } from '../../ftr_provider_context.d'; import { UI_SETTINGS } from '../../../../src/plugins/data/common'; -export default function ({ getService, getPageObjects, loadTestFile }: FtrProviderContext) { +export default function ({ getService, loadTestFile }: FtrProviderContext) { const browser = getService('browser'); const log = getService('log'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects(['common']); + const deployment = getService('deployment'); let isOss = true; describe('visualize app', () => { @@ -39,7 +39,7 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid defaultIndex: 'logstash-*', [UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', }); - isOss = await PageObjects.common.isOss(); + isOss = await deployment.isOss(); }); describe('', function () { diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 4a14d43aec2491..19f35ee3083bd8 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -19,7 +19,6 @@ import { delay } from 'bluebird'; import expect from '@kbn/expect'; -import { get } from 'lodash'; // @ts-ignore import fetch from 'node-fetch'; import { FtrProviderContext } from '../ftr_provider_context'; @@ -48,20 +47,6 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } class CommonPage { - /** - * Returns Kibana host URL - */ - public getHostPort() { - return getUrl.baseUrl(config.get('servers.kibana')); - } - - /** - * Returns ES host URL - */ - public getEsHostPort() { - return getUrl.baseUrl(config.get('servers.elasticsearch')); - } - /** * Logins to Kibana as default user and navigates to provided app * @param appUrl Kibana URL @@ -455,39 +440,6 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo return await body.getVisibleText(); } - /** - * Helper to detect an OSS licensed Kibana - * Useful for functional testing in cloud environment - */ - async isOss() { - const baseUrl = this.getEsHostPort(); - const username = config.get('servers.elasticsearch.username'); - const password = config.get('servers.elasticsearch.password'); - const response = await fetch(baseUrl + '/_xpack', { - method: 'get', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Basic ' + Buffer.from(username + ':' + password).toString('base64'), - }, - }); - return response.status !== 200; - } - - async isCloud(): Promise { - const baseUrl = this.getHostPort(); - const username = config.get('servers.kibana.username'); - const password = config.get('servers.kibana.password'); - const response = await fetch(baseUrl + '/api/stats?extended', { - method: 'get', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Basic ' + Buffer.from(username + ':' + password).toString('base64'), - }, - }); - const data = await response.json(); - return get(data, 'usage.cloud.is_cloud_enabled', false); - } - async waitForSaveModalToClose() { log.debug('Waiting for save modal to close'); await retry.try(async () => { diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index c12c633926c1c2..7f1db636de32db 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -23,6 +23,7 @@ export function HomePageProvider({ getService, getPageObjects }: FtrProviderCont const testSubjects = getService('testSubjects'); const retry = getService('retry'); const find = getService('find'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['common']); let isOss = true; @@ -82,7 +83,7 @@ export function HomePageProvider({ getService, getPageObjects }: FtrProviderCont async launchSampleDashboard(id: string) { await this.launchSampleDataSet(id); - isOss = await PageObjects.common.isOss(); + isOss = await deployment.isOss(); if (!isOss) { await find.clickByLinkText('Dashboard'); } diff --git a/test/functional/services/dashboard/add_panel.ts b/test/functional/services/dashboard/add_panel.ts index 1263501aa9c135..814a9114866988 100644 --- a/test/functional/services/dashboard/add_panel.ts +++ b/test/functional/services/dashboard/add_panel.ts @@ -41,6 +41,11 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }: FtrPro await PageObjects.common.sleep(500); } + async clickVisType(visType: string) { + log.debug('DashboardAddPanel.clickVisType'); + await testSubjects.click(`visType-${visType}`); + } + async clickAddNewEmbeddableLink(type: string) { await testSubjects.click('createNew'); await testSubjects.click(`createNew-${type}`); diff --git a/test/functional/services/remote/remote.ts b/test/functional/services/remote/remote.ts index a45403e31095c7..94511b0bcf5d44 100644 --- a/test/functional/services/remote/remote.ts +++ b/test/functional/services/remote/remote.ts @@ -130,7 +130,7 @@ export async function RemoteProvider({ getService }: FtrProviderContext) { const coverageJson = await driver .executeScript('return window.__coverage__') .catch(() => undefined) - .then((coverage) => coverage && JSON.stringify(coverage)); + .then((coverage) => (coverage ? JSON.stringify(coverage) : undefined)); if (coverageJson) { writeCoverage(coverageJson); } diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index 4c72c091a2bee4..d18fa31b0694b5 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -28,6 +28,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const testSubjects = getService('testSubjects'); const find = getService('find'); const retry = getService('retry'); + const deployment = getService('deployment'); const loadingScreenNotShown = async () => expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false); @@ -55,7 +56,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide }; const navigateTo = async (path: string) => - await browser.navigateTo(`${PageObjects.common.getHostPort()}${path}`); + await browser.navigateTo(`${deployment.getHostPort()}${path}`); describe('ui applications', function describeIndexTests() { before(async () => { diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 9931a3fabcd8d3..781e364996a56c 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -32,15 +32,15 @@ declare global { } } -export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { - const PageObjects = getPageObjects(['common']); +export default function ({ getService }: PluginFunctionalProviderContext) { const appsMenu = getService('appsMenu'); const browser = getService('browser'); + const deployment = getService('deployment'); const find = getService('find'); const testSubjects = getService('testSubjects'); const navigateTo = async (path: string) => - await browser.navigateTo(`${PageObjects.common.getHostPort()}${path}`); + await browser.navigateTo(`${deployment.getHostPort()}${path}`); const navigateToApp = async (title: string) => { await appsMenu.clickLink(title); return browser.execute(() => { diff --git a/test/plugin_functional/test_suites/core_plugins/top_nav.ts b/test/plugin_functional/test_suites/core_plugins/top_nav.ts index c679ac89f2f610..9420ee2911b98f 100644 --- a/test/plugin_functional/test_suites/core_plugins/top_nav.ts +++ b/test/plugin_functional/test_suites/core_plugins/top_nav.ts @@ -19,15 +19,14 @@ import expect from '@kbn/expect'; import { PluginFunctionalProviderContext } from '../../services'; -export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { - const PageObjects = getPageObjects(['common']); - +export default function ({ getService }: PluginFunctionalProviderContext) { const browser = getService('browser'); + const deployment = getService('deployment'); const testSubjects = getService('testSubjects'); describe.skip('top nav', function describeIndexTests() { before(async () => { - const url = `${PageObjects.common.getHostPort()}/app/kbn_tp_top_nav/`; + const url = `${deployment.getHostPort()}/app/kbn_tp_top_nav/`; await browser.get(url); }); diff --git a/x-pack/examples/alerting_example/public/plugin.tsx b/x-pack/examples/alerting_example/public/plugin.tsx index eebb1e2687acc9..5e552bd1b1800d 100644 --- a/x-pack/examples/alerting_example/public/plugin.tsx +++ b/x-pack/examples/alerting_example/public/plugin.tsx @@ -12,7 +12,10 @@ import { } from '../../../../src/core/public'; import { PluginSetupContract as AlertingSetup } from '../../../plugins/alerts/public'; import { ChartsPluginStart } from '../../../../src/plugins/charts/public'; -import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, +} from '../../../plugins/triggers_actions_ui/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { getAlertType as getAlwaysFiringAlertType } from './alert_types/always_firing'; import { getAlertType as getPeopleInSpaceAlertType } from './alert_types/astros'; @@ -30,7 +33,7 @@ export interface AlertingExamplePublicSetupDeps { export interface AlertingExamplePublicStartDeps { alerts: AlertingSetup; - triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; charts: ChartsPluginStart; data: DataPublicPluginStart; } diff --git a/x-pack/plugins/apm/common/agent_name.test.ts b/x-pack/plugins/apm/common/agent_name.test.ts new file mode 100644 index 00000000000000..f4ac2aa220e897 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_name.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getFirstTransactionType, + isJavaAgentName, + isRumAgentName, +} from './agent_name'; + +describe('agent name helpers', () => { + describe('getFirstTransactionType', () => { + describe('with no transaction types', () => { + expect(getFirstTransactionType([])).toBeUndefined(); + }); + + describe('with a non-rum agent', () => { + it('returns "request"', () => { + expect(getFirstTransactionType(['worker', 'request'], 'java')).toEqual( + 'request' + ); + }); + + describe('with no request types', () => { + it('returns the first type', () => { + expect( + getFirstTransactionType(['worker', 'shirker'], 'java') + ).toEqual('worker'); + }); + }); + }); + + describe('with a rum agent', () => { + it('returns "page-load"', () => { + expect( + getFirstTransactionType(['http-request', 'page-load'], 'js-base') + ).toEqual('page-load'); + }); + }); + }); + + describe('isJavaAgentName', () => { + describe('when the agent name is java', () => { + it('returns true', () => { + expect(isJavaAgentName('java')).toEqual(true); + }); + }); + describe('when the agent name is not java', () => { + it('returns true', () => { + expect(isJavaAgentName('not java')).toEqual(false); + }); + }); + }); + + describe('isRumAgentName', () => { + describe('when the agent name is js-base', () => { + it('returns true', () => { + expect(isRumAgentName('js-base')).toEqual(true); + }); + }); + + describe('when the agent name is rum-js', () => { + it('returns true', () => { + expect(isRumAgentName('rum-js')).toEqual(true); + }); + }); + + describe('when the agent name is opentelemetry/webjs', () => { + it('returns true', () => { + expect(isRumAgentName('opentelemetry/webjs')).toEqual(true); + }); + }); + + describe('when the agent name something else', () => { + it('returns true', () => { + expect(isRumAgentName('java')).toEqual(false); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index ca9e59e050c95c..916fe65684a6b0 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -5,6 +5,10 @@ */ import { AgentName } from '../typings/es_schemas/ui/fields/agent'; +import { + TRANSACTION_PAGE_LOAD, + TRANSACTION_REQUEST, +} from './transaction_types'; /* * Agent names can be any string. This list only defines the official agents @@ -46,10 +50,24 @@ export const RUM_AGENT_NAMES: AgentName[] = [ 'opentelemetry/webjs', ]; -export function isRumAgentName( +function getDefaultTransactionTypeForAgentName(agentName?: string) { + return isRumAgentName(agentName) + ? TRANSACTION_PAGE_LOAD + : TRANSACTION_REQUEST; +} + +export function getFirstTransactionType( + transactionTypes: string[], agentName?: string -): agentName is 'js-base' | 'rum-js' | 'opentelemetry/webjs' { - return RUM_AGENT_NAMES.includes(agentName! as AgentName); +) { + const defaultTransactionType = getDefaultTransactionTypeForAgentName( + agentName + ); + + return ( + transactionTypes.find((type) => type === defaultTransactionType) ?? + transactionTypes[0] + ); } export function isJavaAgentName( @@ -57,3 +75,9 @@ export function isJavaAgentName( ): agentName is 'java' { return agentName === 'java'; } + +export function isRumAgentName( + agentName?: string +): agentName is 'js-base' | 'rum-js' | 'opentelemetry/webjs' { + return RUM_AGENT_NAMES.includes(agentName! as AgentName); +} diff --git a/x-pack/plugins/apm/jest.config.js b/x-pack/plugins/apm/jest.config.js index 849dd7f5c3e2df..2a5ef9ad0c2a7b 100644 --- a/x-pack/plugins/apm/jest.config.js +++ b/x-pack/plugins/apm/jest.config.js @@ -14,14 +14,9 @@ const { createJestConfig } = require('../../dev-tools/jest/create_jest_config'); const { resolve } = require('path'); const rootDir = resolve(__dirname, '.'); -const xPackKibanaDirectory = resolve(__dirname, '../..'); const kibanaDirectory = resolve(__dirname, '../../..'); -const jestConfig = createJestConfig({ - kibanaDirectory, - rootDir, - xPackKibanaDirectory, -}); +const jestConfig = createJestConfig({ kibanaDirectory, rootDir }); module.exports = { ...jestConfig, diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx index 22c5a2b101ddcb..92eb3753e79895 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx @@ -23,7 +23,7 @@ import { ServiceMap } from '../ServiceMap'; import { ServiceMetrics } from '../service_metrics'; import { ServiceNodeOverview } from '../ServiceNodeOverview'; import { ServiceOverview } from '../service_overview'; -import { TransactionOverview } from '../TransactionOverview'; +import { TransactionOverview } from '../transaction_overview'; interface Tab { key: string; diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index f734abe27573ca..33027f3946d1fb 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -16,6 +16,7 @@ import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink'; import { SearchBar } from '../../shared/search_bar'; @@ -103,22 +104,7 @@ export function ServiceOverview({ - - - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.averageDurationBySpanTypeChartTitle', - { - defaultMessage: 'Average duration by span type', - } - )} -

-
-
-
-
+
diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index 8f9e76a5a79a65..e4ef7428ba8d4c 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -17,6 +17,8 @@ import { MockUrlParamsContextProvider } from '../../../context/UrlParamsContext/ import * as useDynamicIndexPatternHooks from '../../../hooks/useDynamicIndexPattern'; import * as useFetcherHooks from '../../../hooks/useFetcher'; import { FETCH_STATUS } from '../../../hooks/useFetcher'; +import * as useAnnotationsHooks from '../../../hooks/use_annotations'; +import * as useTransactionBreakdownHooks from '../../../hooks/use_transaction_breakdown'; import { renderWithTheme } from '../../../utils/testHelpers'; import { ServiceOverview } from './'; @@ -53,6 +55,9 @@ function Wrapper({ children }: { children?: ReactNode }) { describe('ServiceOverview', () => { it('renders', () => { + jest + .spyOn(useAnnotationsHooks, 'useAnnotations') + .mockReturnValue({ annotations: [] }); jest .spyOn(useDynamicIndexPatternHooks, 'useDynamicIndexPattern') .mockReturnValue({ @@ -71,6 +76,13 @@ describe('ServiceOverview', () => { refetch: () => {}, status: FETCH_STATUS.SUCCESS, }); + jest + .spyOn(useTransactionBreakdownHooks, 'useTransactionBreakdown') + .mockReturnValue({ + data: { timeseries: [] }, + error: undefined, + status: FETCH_STATUS.SUCCESS, + }); expect(() => renderWithTheme(, { diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx rename to x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/index.tsx rename to x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx similarity index 91% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx rename to x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index a55b135c6a84e2..45a6114c88afdb 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -18,7 +18,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { Location } from 'history'; -import { first } from 'lodash'; import React, { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useTrackPageview } from '../../../../../observability/public'; @@ -29,6 +28,7 @@ import { useServiceTransactionTypes } from '../../../hooks/useServiceTransaction import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; import { useTransactionList } from '../../../hooks/useTransactionList'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useTransactionType } from '../../../hooks/use_transaction_type'; import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; @@ -41,23 +41,22 @@ import { useRedirect } from './useRedirect'; import { UserExperienceCallout } from './user_experience_callout'; function getRedirectLocation({ - urlParams, location, - serviceTransactionTypes, + transactionType, + urlParams, }: { location: Location; + transactionType?: string; urlParams: IUrlParams; - serviceTransactionTypes: string[]; }): Location | undefined { - const { transactionType } = urlParams; - const firstTransactionType = first(serviceTransactionTypes); + const transactionTypeFromUrlParams = urlParams.transactionType; - if (!transactionType && firstTransactionType) { + if (!transactionTypeFromUrlParams && transactionType) { return { ...location, search: fromQuery({ ...toQuery(location.search), - transactionType: firstTransactionType, + transactionType, }), }; } @@ -70,19 +69,11 @@ interface TransactionOverviewProps { export function TransactionOverview({ serviceName }: TransactionOverviewProps) { const location = useLocation(); const { urlParams } = useUrlParams(); - const { transactionType } = urlParams; - - // TODO: fetching of transaction types should perhaps be lifted since it is needed in several places. Context? + const transactionType = useTransactionType(); const serviceTransactionTypes = useServiceTransactionTypes(urlParams); // redirect to first transaction type - useRedirect( - getRedirectLocation({ - urlParams, - location, - serviceTransactionTypes, - }) - ); + useRedirect(getRedirectLocation({ location, transactionType, urlParams })); const { data: transactionCharts, diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx rename to x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/useRedirect.ts b/x-pack/plugins/apm/public/components/app/transaction_overview/useRedirect.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/useRedirect.ts rename to x-pack/plugins/apm/public/components/app/transaction_overview/useRedirect.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/user_experience_callout.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/user_experience_callout.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/user_experience_callout.tsx rename to x-pack/plugins/apm/public/components/app/transaction_overview/user_experience_callout.tsx diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx index 7e18132e59cf31..b13b1f89da352a 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx @@ -22,7 +22,7 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(mlTimeSeriesExplorer:())"` + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(timeseriesexplorer:(mlTimeSeriesExplorer:()))"` ); }); it('should produce the correct URL with jobId, serviceName, and transactionType', async () => { @@ -41,7 +41,7 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-test,transaction.type:request)))"` + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(timeseriesexplorer:(mlTimeSeriesExplorer:(entities:(service.name:opbeans-test,transaction.type:request))))"` ); }); @@ -61,7 +61,7 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(apm-production-485b-high_mean_transaction_duration)),refreshInterval:(pause:!t,value:10000),time:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-java,transaction.type:request)))"` + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(apm-production-485b-high_mean_transaction_duration)),refreshInterval:(pause:!t,value:10000),time:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))&_a=(timeseriesexplorer:(mlTimeSeriesExplorer:(entities:(service.name:opbeans-java,transaction.type:request))))"` ); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index 6f1f4e01c4d1f6..73a819af2d624a 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx @@ -46,17 +46,7 @@ export function SparkPlot(props: Props) { return ( - + (); + const chartTheme = useChartTheme(); const { event, setEvent } = useChartsSync(); const { urlParams } = useUrlParams(); const { start, end } = urlParams; @@ -74,13 +75,6 @@ export function TimeseriesChart({ const xFormatter = niceTimeFormatter([min, max]); - const chartTheme: SettingsSpec['theme'] = { - lineSeriesStyle: { - point: { visible: false }, - line: { strokeWidth: 2 }, - }, - }; - const isEmpty = timeseries .map((serie) => serie.data) .flat() diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/index.tsx similarity index 66% rename from x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/index.tsx index 9b0c041aaf7b53..4d9a1637bea760 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/index.tsx @@ -6,10 +6,16 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { useTransactionBreakdown } from '../../../hooks/useTransactionBreakdown'; -import { TransactionBreakdownGraph } from './TransactionBreakdownGraph'; +import { useTransactionBreakdown } from '../../../../hooks/use_transaction_breakdown'; +import { TransactionBreakdownChartContents } from './transaction_breakdown_chart_contents'; -function TransactionBreakdown() { +export function TransactionBreakdownChart({ + height, + showAnnotations = true, +}: { + height?: number; + showAnnotations?: boolean; +}) { const { data, status } = useTransactionBreakdown(); const { timeseries } = data; @@ -20,20 +26,20 @@ function TransactionBreakdown() {

{i18n.translate('xpack.apm.transactionBreakdown.chartTitle', { - defaultMessage: 'Time spent by span type', + defaultMessage: 'Average duration by span type', })}

-
); } - -export { TransactionBreakdown }; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx similarity index 88% rename from x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx index 677e4b7593ff10..8070868f831b2e 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx @@ -18,6 +18,7 @@ import { import moment from 'moment'; import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; +import { useChartTheme } from '../../../../../../observability/public'; import { asPercent } from '../../../../../common/utils/formatters'; import { TimeSeries } from '../../../../../typings/timeseries'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; @@ -28,16 +29,22 @@ import { Annotations } from '../../charts/annotations'; import { ChartContainer } from '../../charts/chart_container'; import { onBrushEnd } from '../../charts/helper/helper'; -const XY_HEIGHT = unit * 16; - interface Props { fetchStatus: FETCH_STATUS; + height?: number; + showAnnotations: boolean; timeseries?: TimeSeries[]; } -export function TransactionBreakdownGraph({ fetchStatus, timeseries }: Props) { +export function TransactionBreakdownChartContents({ + fetchStatus, + height = unit * 16, + showAnnotations, + timeseries, +}: Props) { const history = useHistory(); const chartRef = React.createRef(); + const chartTheme = useChartTheme(); const { event, setEvent } = useChartsSync2(); const { urlParams } = useUrlParams(); const { start, end } = urlParams; @@ -54,17 +61,14 @@ export function TransactionBreakdownGraph({ fetchStatus, timeseries }: Props) { const xFormatter = niceTimeFormatter([min, max]); return ( - + onBrushEnd({ x, history })} showLegend showLegendExtra legendPosition={Position.Bottom} + theme={chartTheme} xDomain={{ min, max }} flatLegend onPointerUpdate={(currEvent: any) => { @@ -87,7 +91,7 @@ export function TransactionBreakdownGraph({ fetchStatus, timeseries }: Props) { tickFormat={(y: number) => asPercent(y ?? 0, 1)} /> - + {showAnnotations && } {timeseries?.length ? ( timeseries.map((serie) => { diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index 41212aa7b982c7..61d834abda7937 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -28,7 +28,7 @@ import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { ITransactionChartData } from '../../../../selectors/chart_selectors'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { TransactionBreakdown } from '../../TransactionBreakdown'; +import { TransactionBreakdownChart } from '../transaction_breakdown_chart'; import { TimeseriesChart } from '../timeseries_chart'; import { TransactionErrorRateChart } from '../transaction_error_rate_chart/'; import { getResponseTimeTickFormatter } from './helper'; @@ -117,7 +117,7 @@ export function TransactionCharts({
- + diff --git a/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts b/x-pack/plugins/apm/public/hooks/use_transaction_breakdown.ts similarity index 84% rename from x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts rename to x-pack/plugins/apm/public/hooks/use_transaction_breakdown.ts index 14832476864295..686501c1eef4c8 100644 --- a/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_breakdown.ts @@ -7,13 +7,13 @@ import { useParams } from 'react-router-dom'; import { useFetcher } from './useFetcher'; import { useUrlParams } from './useUrlParams'; +import { useTransactionType } from './use_transaction_type'; export function useTransactionBreakdown() { const { serviceName } = useParams<{ serviceName?: string }>(); - const { - urlParams: { start, end, transactionName, transactionType }, - uiFilters, - } = useUrlParams(); + const { urlParams, uiFilters } = useUrlParams(); + const { start, end, transactionName } = urlParams; + const transactionType = useTransactionType(); const { data = { timeseries: undefined }, error, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_type.ts b/x-pack/plugins/apm/public/hooks/use_transaction_type.ts new file mode 100644 index 00000000000000..fd4e6516f9ca31 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_transaction_type.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 { getFirstTransactionType } from '../../common/agent_name'; +import { useAgentName } from './useAgentName'; +import { useServiceTransactionTypes } from './useServiceTransactionTypes'; +import { useUrlParams } from './useUrlParams'; + +/** + * Get either the transaction type from the URL parameters, "request" + * (for non-RUM agents), "page-load" (for RUM agents) if this service uses them, + * or the first available transaction type. + */ +export function useTransactionType() { + const { agentName } = useAgentName(); + const { urlParams } = useUrlParams(); + const transactionTypeFromUrlParams = urlParams.transactionType; + const transactionTypes = useServiceTransactionTypes(urlParams); + const firstTransactionType = getFirstTransactionType( + transactionTypes, + agentName + ); + + return transactionTypeFromUrlParams ?? firstTransactionType; +} diff --git a/x-pack/plugins/beats_management/public/components/inputs/code_editor.tsx b/x-pack/plugins/beats_management/public/components/inputs/code_editor.tsx index 46ea90a9c1b301..78eecf79848659 100644 --- a/x-pack/plugins/beats_management/public/components/inputs/code_editor.tsx +++ b/x-pack/plugins/beats_management/public/components/inputs/code_editor.tsx @@ -68,6 +68,7 @@ class CodeEditor extends Component< public render() { const { + name, id, label, isReadOnly, diff --git a/x-pack/plugins/beats_management/public/components/inputs/input.tsx b/x-pack/plugins/beats_management/public/components/inputs/input.tsx index 29cdcfccfc756c..17f2f95070c59c 100644 --- a/x-pack/plugins/beats_management/public/components/inputs/input.tsx +++ b/x-pack/plugins/beats_management/public/components/inputs/input.tsx @@ -71,6 +71,7 @@ class FieldText extends Component< public render() { const { + name, id, required, label, diff --git a/x-pack/plugins/beats_management/public/components/inputs/multi_input.tsx b/x-pack/plugins/beats_management/public/components/inputs/multi_input.tsx index 16bcf1b3b9a06b..ed0d67bb221495 100644 --- a/x-pack/plugins/beats_management/public/components/inputs/multi_input.tsx +++ b/x-pack/plugins/beats_management/public/components/inputs/multi_input.tsx @@ -73,6 +73,7 @@ class MultiFieldText extends Component< public render() { const { + name, id, required, label, diff --git a/x-pack/plugins/beats_management/public/components/inputs/password_input.tsx b/x-pack/plugins/beats_management/public/components/inputs/password_input.tsx index 30f4cb85fb58c1..edb8cf6ab3abc4 100644 --- a/x-pack/plugins/beats_management/public/components/inputs/password_input.tsx +++ b/x-pack/plugins/beats_management/public/components/inputs/password_input.tsx @@ -67,6 +67,7 @@ class FieldPassword extends Component< public render() { const { + name, id, required, label, diff --git a/x-pack/plugins/canvas/README.md b/x-pack/plugins/canvas/README.md index 7bd9a1994ba7ed..f77585b5b062c0 100644 --- a/x-pack/plugins/canvas/README.md +++ b/x-pack/plugins/canvas/README.md @@ -149,7 +149,7 @@ yarn start #### Adding a server-side function -> Server side functions may be deprecated in a later version of Kibana as they require using an API marked _legacy_ +> Server side functions may be deprecated in a later version of Kibana Now, let's add a function which runs on the server. @@ -206,9 +206,7 @@ And then in our setup method, register it with the Expressions plugin: ```typescript setup(core: CoreSetup, plugins: CanvasExamplePluginsSetup) { - // .register requires serverFunctions and types, so pass an empty array - // if you don't have any custom types to register - plugins.expressions.__LEGACY.register({ serverFunctions, types: [] }); + serverFunctions.forEach((f) => plugins.expressions.registerFunction(f)); } ``` diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts index 5c0ca74f5225ac..4eed89b95132a3 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts @@ -28,7 +28,7 @@ export function location(): ExpressionFunctionDefinition<'location', null, {}, P help, fn: () => { return new Promise((resolve) => { - function createLocation(geoposition: Position) { + function createLocation(geoposition: GeolocationPosition) { const { latitude, longitude } = geoposition.coords; return resolve({ type: 'datatable', diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts index 765ff507282289..380d07972ca4dc 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts @@ -83,6 +83,7 @@ export function savedLens(): ExpressionFunctionDefinition< title: args.title === null ? undefined : args.title, disableTriggers: true, palette: args.palette, + renderMode: 'noInteractivity', }, embeddableType: EmbeddableTypes.lens, generatedAt: Date.now(), diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx index 647c63c2c10427..54702f26548393 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx @@ -11,6 +11,7 @@ export const defaultHandlers: RendererHandlers = { destroy: () => action('destroy'), getElementId: () => 'element-id', getFilter: () => 'filter', + getRenderMode: () => 'display', onComplete: (fn) => undefined, onEmbeddableDestroyed: action('onEmbeddableDestroyed'), onEmbeddableInputChange: action('onEmbeddableInputChange'), diff --git a/x-pack/plugins/canvas/public/lib/create_handlers.ts b/x-pack/plugins/canvas/public/lib/create_handlers.ts index ae0956ee21283c..9bc4bd5e78fd08 100644 --- a/x-pack/plugins/canvas/public/lib/create_handlers.ts +++ b/x-pack/plugins/canvas/public/lib/create_handlers.ts @@ -23,6 +23,9 @@ export const createHandlers = (): RendererHandlers => ({ getFilter() { return ''; }, + getRenderMode() { + return 'display'; + }, onComplete(fn: () => void) { this.done = fn; }, diff --git a/x-pack/plugins/canvas/shareable_runtime/test/utils.ts b/x-pack/plugins/canvas/shareable_runtime/test/utils.ts index 5e65594972da2e..939343b6a28c5e 100644 --- a/x-pack/plugins/canvas/shareable_runtime/test/utils.ts +++ b/x-pack/plugins/canvas/shareable_runtime/test/utils.ts @@ -21,7 +21,7 @@ export const takeMountedSnapshot = (mountedComponent: ReactWrapper<{}, {}, Compo }; export const waitFor = (fn: () => boolean, stepMs = 100, failAfterMs = 1000) => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let waitForTimeout: NodeJS.Timeout; const tryCondition = () => { diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts index 40e7691e621fd3..30de6c0802713a 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts @@ -42,7 +42,7 @@ export abstract class AbstractExploreDataAction; + protected abstract getUrl(context: Context): Promise; public async isCompatible({ embeddable }: Context): Promise { if (!embeddable) return false; diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index d60ab5c7d37f07..36a3895c616152 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "requiredPlugins": ["features", "licensing"], "configPath": ["enterpriseSearch"], - "optionalPlugins": ["usageCollection", "security", "home", "spaces"], + "optionalPlugins": ["usageCollection", "security", "home", "spaces", "cloud"], "server": true, "ui": true, "requiredBundles": ["home"] diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_logic.mock.ts index ab91666d4acb65..95843a243a3c66 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_logic.mock.ts @@ -9,6 +9,10 @@ import { mockHistory } from './'; export const mockKibanaValues = { config: { host: 'http://localhost:3002' }, history: mockHistory, + cloud: { + isCloudEnabled: false, + cloudDeploymentUrl: 'https://cloud.elastic.co/deployments/some-id', + }, navigateToUrl: jest.fn(), setBreadcrumbs: jest.fn(), setDocTitle: jest.fn(), diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx index c8872fe43a1846..ea7eeea750cc48 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx @@ -17,23 +17,27 @@ import { EnginesTable } from './engines_table'; describe('EnginesTable', () => { const onPaginate = jest.fn(); // onPaginate updates the engines API call upstream - const wrapper = mountWithIntl( - - ); + const data = [ + { + name: 'test-engine', + created_at: 'Fri, 1 Jan 1970 12:00:00 +0000', + language: 'English', + isMeta: false, + document_count: 99999, + field_count: 10, + }, + ]; + const pagination = { + totalEngines: 50, + pageIndex: 0, + onPaginate, + }; + const props = { + data, + pagination, + }; + + const wrapper = mountWithIntl(); const table = wrapper.find(EuiBasicTable); it('renders', () => { @@ -42,7 +46,8 @@ describe('EnginesTable', () => { const tableContent = table.text(); expect(tableContent).toContain('test-engine'); - expect(tableContent).toContain('January 1, 1970'); + expect(tableContent).toContain('Jan 1, 1970'); + expect(tableContent).toContain('English'); expect(tableContent).toContain('99,999'); expect(tableContent).toContain('10'); @@ -80,4 +85,57 @@ describe('EnginesTable', () => { expect(emptyTable.prop('pagination').pageIndex).toEqual(0); }); + + describe('language field', () => { + it('renders language when available', () => { + const wrapperWithLanguage = mountWithIntl( + + ); + const tableContent = wrapperWithLanguage.find(EuiBasicTable).text(); + expect(tableContent).toContain('German'); + }); + + it('renders the language as Universal if no language is set', () => { + const wrapperWithLanguage = mountWithIntl( + + ); + const tableContent = wrapperWithLanguage.find(EuiBasicTable).text(); + expect(tableContent).toContain('Universal'); + }); + + it('renders no language text if the engine is a Meta Engine', () => { + const wrapperWithLanguage = mountWithIntl( + + ); + const tableContent = wrapperWithLanguage.find(EuiBasicTable).text(); + expect(tableContent).not.toContain('Universal'); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx index 7d69cd2b4d4da0..e9805ab8f27110 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx @@ -15,12 +15,15 @@ import { EuiLinkTo } from '../../../shared/react_router_helpers'; import { getEngineRoute } from '../../routes'; import { ENGINES_PAGE_SIZE } from '../../../../../common/constants'; +import { UNIVERSAL_LANGUAGE } from '../../constants'; interface EnginesTableData { name: string; created_at: string; document_count: number; field_count: number; + language: string | null; + isMeta: boolean; } interface EnginesTablePagination { totalEngines: number; @@ -84,10 +87,22 @@ export const EnginesTable: React.FC = ({ ), dataType: 'string', render: (dateString: string) => ( - // e.g., January 1, 1970 - + // e.g., Jan 1, 1970 + ), }, + { + field: 'language', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.enginesOverview.table.column.language', + { + defaultMessage: 'Language', + } + ), + dataType: 'string', + render: (language: string, engine: EnginesTableData) => + engine.isMeta ? '' : language || UNIVERSAL_LANGUAGE, + }, { field: 'document_count', name: i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx index 5936b8f2d4283b..8aa8731d6da48b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetupGuideLayout } from '../../../shared/setup_guide'; import { SetupGuide } from './'; describe('SetupGuide', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx index b3faa73dfaed6a..ec340f70fa7b1d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.tsx @@ -10,7 +10,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; -import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetupGuideLayout, SETUP_GUIDE_TITLE } from '../../../shared/setup_guide'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { DOCS_PREFIX } from '../../routes'; @@ -23,13 +23,7 @@ export const SetupGuide: React.FC = () => ( standardAuthLink={`${DOCS_PREFIX}/security-and-users.html#app-search-self-managed-security-and-user-management-standard`} elasticsearchNativeAuthLink={`${DOCS_PREFIX}/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm`} > - + { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.tsx index 4197813feba0f7..7f1924d2870d26 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/setup_guide/setup_guide.tsx @@ -10,7 +10,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { ENTERPRISE_SEARCH_PLUGIN } from '../../../../../common/constants'; -import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetupGuideLayout, SETUP_GUIDE_TITLE } from '../../../shared/setup_guide'; import { SetEnterpriseSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendEnterpriseSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import GettingStarted from './assets/getting_started.png'; @@ -22,13 +22,7 @@ export const SetupGuide: React.FC = () => ( standardAuthLink="https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-standard" elasticsearchNativeAuthLink="https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm" > - + diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 3436df851c8d81..1271015e40e52e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -41,6 +41,7 @@ export const renderApp = ( const unmountKibanaLogic = mountKibanaLogic({ config, + cloud: plugins.cloud || {}, history: params.history, navigateToUrl: core.application.navigateToUrl, setBreadcrumbs: core.chrome.setBreadcrumbs, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/documentation_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/documentation_links.ts new file mode 100644 index 00000000000000..7e774616ff5989 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/documentation_links.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CURRENT_MAJOR_VERSION } from '../../../../common/version'; + +export const ENT_SEARCH_DOCS_PREFIX = `https://www.elastic.co/guide/en/enterprise-search/${CURRENT_MAJOR_VERSION}`; + +export const CLOUD_DOCS_PREFIX = `https://www.elastic.co/guide/en/cloud/current`; // Cloud does not have version-prefixed documentation diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts index 4d4ff5f52ef20c..8fa3ccdcb863e6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts @@ -5,3 +5,4 @@ */ export { DEFAULT_META } from './default_meta'; +export * from './documentation_links'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts index a763518d30b992..3115e233a6058b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts @@ -34,6 +34,12 @@ describe('KibanaLogic', () => { expect(KibanaLogic.values.config).toEqual({}); }); + + it('gracefully handles non-cloud installs', () => { + mountKibanaLogic({ ...mockKibanaValues, cloud: undefined } as any); + + expect(KibanaLogic.values.cloud).toEqual({}); + }); }); describe('navigateToUrl()', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts index 28f500a2c8a39f..7d3db4d36692e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts @@ -9,6 +9,7 @@ import { kea, MakeLogicType } from 'kea'; import { FC } from 'react'; import { History } from 'history'; import { ApplicationStart, ChromeBreadcrumb } from 'src/core/public'; +import { CloudSetup } from '../../../../../cloud/public'; import { HttpLogic } from '../http'; import { createHref, CreateHrefOptions } from '../react_router_helpers'; @@ -16,6 +17,7 @@ import { createHref, CreateHrefOptions } from '../react_router_helpers'; interface KibanaLogicProps { config: { host?: string }; history: History; + cloud: Partial; navigateToUrl: ApplicationStart['navigateToUrl']; setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void; setDocTitle(title: string): void; @@ -30,6 +32,7 @@ export const KibanaLogic = kea>({ reducers: ({ props }) => ({ config: [props.config || {}, {}], history: [props.history, {}], + cloud: [props.cloud || {}, {}], navigateToUrl: [ (url: string, options?: CreateHrefOptions) => { const deps = { history: props.history, http: HttpLogic.values.http }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.test.tsx new file mode 100644 index 00000000000000..3c93e3fd49dcc5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.test.tsx @@ -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 { shallow } from 'enzyme'; +import { EuiSteps, EuiLink } from '@elastic/eui'; + +import { mountWithIntl } from '../../../__mocks__'; + +import { CloudSetupInstructions } from './instructions'; + +describe('CloudSetupInstructions', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiSteps)).toHaveLength(1); + }); + + it('renders with a link to the Elastic Cloud deployment', () => { + const wrapper = mountWithIntl( + + ); + const cloudDeploymentLink = wrapper.find(EuiLink).first(); + expect(cloudDeploymentLink.prop('href')).toEqual( + 'https://cloud.elastic.co/deployments/some-id' + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx new file mode 100644 index 00000000000000..7a7dfa62dbe398 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiPageContent, EuiSteps, EuiText, EuiLink, EuiCallOut } from '@elastic/eui'; + +import { CLOUD_DOCS_PREFIX, ENT_SEARCH_DOCS_PREFIX } from '../../constants'; + +interface Props { + productName: string; + cloudDeploymentLink?: string; +} + +export const CloudSetupInstructions: React.FC = ({ productName, cloudDeploymentLink }) => ( + + +

+ + Visit the Elastic Cloud console + + ) : ( + 'Visit the Elastic Cloud console' + ), + }} + /> +

+ + ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.cloud.step2.title', { + defaultMessage: 'Enable Enterprise Search for your deployment', + }), + children: ( + +

+ +

+
+ ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.cloud.step3.title', { + defaultMessage: 'Configure your Enterprise Search instance', + }), + children: ( + +

+ + configurable options + + ), + }} + /> +

+
+ ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.cloud.step4.title', { + defaultMessage: 'Save your deployment configuration', + }), + children: ( + +

+ +

+
+ ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.cloud.step5.title', { + defaultMessage: '{productName} is now available to use', + values: { productName }, + }), + children: ( + +

+ + configure an index lifecycle policy + + ), + }} + /> +

+
+ ), + }, + ]} + /> +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/constants.ts new file mode 100644 index 00000000000000..dc84f20fade031 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/constants.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const SETUP_GUIDE_TITLE = i18n.translate('xpack.enterpriseSearch.setupGuide.title', { + defaultMessage: 'Setup Guide', +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts index c367424d375f9d..e958bf477c6f66 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SetupGuide } from './setup_guide'; +export { SetupGuideLayout } from './setup_guide'; +export { SETUP_GUIDE_TITLE } from './constants'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/instructions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/instructions.test.tsx new file mode 100644 index 00000000000000..7c661354289aa2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/instructions.test.tsx @@ -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 { shallow } from 'enzyme'; +import { EuiSteps, EuiLink } from '@elastic/eui'; + +import { mountWithIntl } from '../../__mocks__'; + +import { SetupInstructions } from './instructions'; + +describe('SetupInstructions', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiSteps)).toHaveLength(1); + }); + + it('renders with auth links', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiLink).first().prop('href')).toEqual('http://bar.com'); + expect(wrapper.find(EuiLink).last().prop('href')).toEqual('http://foo.com'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/instructions.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/instructions.tsx new file mode 100644 index 00000000000000..91f6a770edd7d6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/instructions.tsx @@ -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 React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + EuiPageContent, + EuiSpacer, + EuiText, + EuiSteps, + EuiCode, + EuiCodeBlock, + EuiAccordion, + EuiLink, +} from '@elastic/eui'; + +interface Props { + productName: string; + standardAuthLink?: string; + elasticsearchNativeAuthLink?: string; +} + +export const SetupInstructions: React.FC = ({ + productName, + standardAuthLink, + elasticsearchNativeAuthLink, +}) => ( + + +

+ config/kibana.yml, + configSetting: enterpriseSearch.host, + }} + /> +

+ + enterpriseSearch.host: 'http://localhost:3002' + + + ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step2.title', { + defaultMessage: 'Reload your Kibana instance', + }), + children: ( + +

+ +

+

+ + Elasticsearch Native Auth + + ) : ( + 'Elasticsearch Native Auth' + ), + }} + /> +

+
+ ), + }, + { + title: i18n.translate('xpack.enterpriseSearch.setupGuide.step3.title', { + defaultMessage: 'Troubleshooting issues', + }), + children: ( + <> + + +

+ +

+
+
+ + + +

+ +

+
+
+ + + +

+ + Standard Auth + + ) : ( + 'Standard Auth' + ), + }} + /> +

+
+
+ + ), + }, + ]} + /> +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx index 802a10e3b3db76..748f4b06f7cac6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.test.tsx @@ -4,41 +4,46 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../__mocks__/kea.mock'; +import { rerender } from '../../__mocks__'; + import React from 'react'; -import { shallow } from 'enzyme'; -import { EuiSteps, EuiIcon, EuiLink } from '@elastic/eui'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { EuiIcon } from '@elastic/eui'; -import { mountWithIntl } from '../../__mocks__'; +import { SetupInstructions } from './instructions'; +import { CloudSetupInstructions } from './cloud/instructions'; -import { SetupGuide } from './'; +import { SetupGuideLayout } from './'; -describe('SetupGuide', () => { - it('renders', () => { - const wrapper = shallow( - +describe('SetupGuideLayout', () => { + let wrapper: ShallowWrapper; + + beforeAll(() => { + setMockValues({ isCloudEnabled: false }); + wrapper = shallow( +

Wow!

-
+ ); + }); + it('renders', () => { expect(wrapper.find('h1').text()).toEqual('Enterprise Search'); expect(wrapper.find(EuiIcon).prop('type')).toEqual('logoEnterpriseSearch'); expect(wrapper.find('[data-test-subj="test"]').text()).toEqual('Wow!'); - expect(wrapper.find(EuiSteps)).toHaveLength(1); }); - it('renders with optional auth links', () => { - const wrapper = mountWithIntl( - - Baz - - ); + it('renders with default self-managed instructions', () => { + expect(wrapper.find(SetupInstructions)).toHaveLength(1); + expect(wrapper.find(CloudSetupInstructions)).toHaveLength(0); + }); + + it('renders with cloud instructions', () => { + setMockValues({ cloud: { isCloudEnabled: true } }); + rerender(wrapper); - expect(wrapper.find(EuiLink).first().prop('href')).toEqual('http://bar.com'); - expect(wrapper.find(EuiLink).last().prop('href')).toEqual('http://foo.com'); + expect(wrapper.find(SetupInstructions)).toHaveLength(0); + expect(wrapper.find(CloudSetupInstructions)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx index c96e95a41f2e4a..fcae2fb87683a8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/setup_guide.tsx @@ -5,26 +5,25 @@ */ import React from 'react'; +import { useValues } from 'kea'; + import { EuiPage, EuiPageSideBar, EuiPageBody, - EuiPageContent, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiText, EuiIcon, - EuiSteps, - EuiCode, - EuiCodeBlock, - EuiAccordion, - EuiLink, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; +import { KibanaLogic } from '../kibana'; + +import { SetupInstructions } from './instructions'; +import { CloudSetupInstructions } from './cloud/instructions'; +import { SETUP_GUIDE_TITLE } from './constants'; import './setup_guide.scss'; /** @@ -32,7 +31,7 @@ import './setup_guide.scss'; * customizable, but the basic layout and instruction steps are DRYed out */ -interface SetupGuideProps { +interface Props { children: React.ReactNode; productName: string; productEuiIcon: 'logoAppSearch' | 'logoWorkplaceSearch' | 'logoEnterpriseSearch'; @@ -40,187 +39,53 @@ interface SetupGuideProps { elasticsearchNativeAuthLink?: string; } -export const SetupGuide: React.FC = ({ +export const SetupGuideLayout: React.FC = ({ children, productName, productEuiIcon, standardAuthLink, elasticsearchNativeAuthLink, -}) => ( - - - - - - - - +}) => { + const { cloud } = useValues(KibanaLogic); + const isCloudEnabled = Boolean(cloud.isCloudEnabled); + const cloudDeploymentLink = cloud.cloudDeploymentUrl || ''; - - - - - - -

{productName}

-
-
-
+ return ( + + + + {SETUP_GUIDE_TITLE} + + - {children} - + + + + + + +

{productName}

+
+
+
- - - -

- config/kibana.yml, - configSetting: enterpriseSearch.host, - }} - /> -

- - enterpriseSearch.host: 'http://localhost:3002' - - - ), - }, - { - title: i18n.translate('xpack.enterpriseSearch.setupGuide.step2.title', { - defaultMessage: 'Reload your Kibana instance', - }), - children: ( - -

- -

-

- - Elasticsearch Native Auth - - ) : ( - 'Elasticsearch Native Auth' - ), - }} - /> -

-
- ), - }, - { - title: i18n.translate('xpack.enterpriseSearch.setupGuide.step3.title', { - defaultMessage: 'Troubleshooting issues', - }), - children: ( - <> - - -

- -

-
-
- - - -

- -

-
-
- - - -

- - Standard Auth - - ) : ( - 'Standard Auth' - ), - }} - /> -

-
-
- - ), - }, - ]} - /> -
-
-
-); + {children} +
+ + + {isCloudEnabled ? ( + + ) : ( + + )} + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/connection_illustration.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/connection_illustration.svg new file mode 100644 index 00000000000000..12b70d908834d6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/connection_illustration.svg @@ -0,0 +1 @@ + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 6fa6698e6b6ba6..de6c75d60189e2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -11,11 +11,9 @@ import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; import { SideNav, SideNavLink } from '../../../shared/layout'; -import { GroupSubNav } from '../../views/groups/components/group_sub_nav'; import { NAV } from '../../constants'; import { - ORG_SOURCES_PATH, SOURCES_PATH, SECURITY_PATH, ROLE_MAPPINGS_PATH, @@ -23,17 +21,22 @@ import { ORG_SETTINGS_PATH, } from '../../routes'; -export const WorkplaceSearchNav: React.FC = () => { +interface Props { + sourcesSubNav?: React.ReactNode; + groupsSubNav?: React.ReactNode; +} + +export const WorkplaceSearchNav: React.FC = ({ sourcesSubNav, groupsSubNav }) => { // TODO: icons return ( {NAV.OVERVIEW} - + {NAV.SOURCES} - }> + {NAV.GROUPS} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts index 5f93694da09b8f..2ac3f518e4e11b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts @@ -30,22 +30,27 @@ import zendesk from './zendesk.svg'; export const images = { box, confluence, + confluenceCloud: confluence, + confluenceServer: confluence, crawler, custom, drive, dropbox, github, + githubEnterpriseServer: github, gmail, googleDrive, google, jira, jiraServer, + jiraCloud: jira, loadingSmall, office365, oneDrive, outlook, people, salesforce, + salesforceSandbox: salesforce, serviceNow, sharePoint, slack, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.scss new file mode 100644 index 00000000000000..b04d5b8bc218fc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.scss @@ -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. + */ + +.wrapped-icon { + width: 30px; + height: 30px; + overflow: hidden; + margin-right: 4px; + position: relative; + display: flex; + justify-content: center; + align-items: center; + + img { + max-width: 100%; + max-height: 100%; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx index c17b89c93a28b5..4007f7a69f77a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx @@ -7,19 +7,21 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiIcon } from '@elastic/eui'; + import { SourceIcon } from './'; describe('SourceIcon', () => { it('renders unwrapped icon', () => { const wrapper = shallow(); - expect(wrapper.find('img')).toHaveLength(1); + expect(wrapper.find(EuiIcon)).toHaveLength(1); expect(wrapper.find('.user-group-source')).toHaveLength(0); }); it('renders wrapped icon', () => { const wrapper = shallow(); - expect(wrapper.find('.user-group-source')).toHaveLength(1); + expect(wrapper.find('.wrapped-icon')).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx index dec9e25fe24409..1af5420a164be3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx @@ -8,6 +8,10 @@ import React from 'react'; import { camelCase } from 'lodash'; +import { EuiIcon } from '@elastic/eui'; + +import './source_icon.scss'; + import { images } from '../assets/source_icons'; import { imagesFull } from '../assets/sources_full_bleed'; @@ -27,14 +31,15 @@ export const SourceIcon: React.FC = ({ fullBleed = false, }) => { const icon = ( - {name} ); return wrapped ? ( -
+
{icon}
) : ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 1846115d739000..327ee7b30582b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -25,15 +25,27 @@ export const NAV = { 'xpack.enterpriseSearch.workplaceSearch.nav.groups.sourcePrioritization', { defaultMessage: 'Source Prioritization' } ), + CONTENT: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.content', { + defaultMessage: 'Content', + }), ROLE_MAPPINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.roleMappings', { defaultMessage: 'Role Mappings', }), SECURITY: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.security', { defaultMessage: 'Security', }), + SCHEMA: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.schema', { + defaultMessage: 'Schema', + }), + DISPLAY_SETTINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.displaySettings', { + defaultMessage: 'Display Settings', + }), SETTINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.settings', { defaultMessage: 'Settings', }), + ADD_SOURCE: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.addSource', { + defaultMessage: 'Add Source', + }), PERSONAL_DASHBOARD: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.nav.personalDashboard', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index 5f1e2dd18d3b64..20b15bcfc45ca5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -57,7 +57,7 @@ describe('WorkplaceSearchConfigured', () => { it('renders layout and header actions', () => { const wrapper = shallow(); - expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy(); + expect(wrapper.find(Layout).first().prop('readOnlyMode')).toBeFalsy(); expect(wrapper.find(Overview)).toHaveLength(1); expect(mockKibanaValues.renderHeaderActions).toHaveBeenCalledWith(WorkplaceSearchHeaderActions); }); @@ -90,6 +90,6 @@ describe('WorkplaceSearchConfigured', () => { const wrapper = shallow(); - expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true); + expect(wrapper.find(Layout).first().prop('readOnlyMode')).toEqual(true); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 776cae24dfdfbe..562a2ffb328886 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -16,13 +16,17 @@ import { AppLogic } from './app_logic'; import { Layout } from '../shared/layout'; import { WorkplaceSearchNav, WorkplaceSearchHeaderActions } from './components/layout'; -import { GROUPS_PATH, SETUP_GUIDE_PATH } from './routes'; +import { GROUPS_PATH, SETUP_GUIDE_PATH, SOURCES_PATH, PERSONAL_SOURCES_PATH } from './routes'; import { SetupGuide } from './views/setup_guide'; import { ErrorState } from './views/error_state'; import { NotFound } from '../shared/not_found'; import { Overview } from './views/overview'; import { GroupsRouter } from './views/groups'; +import { SourcesRouter } from './views/content_sources'; + +import { GroupSubNav } from './views/groups/components/group_sub_nav'; +import { SourceSubNav } from './views/content_sources/components/source_sub_nav'; export const WorkplaceSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); @@ -37,6 +41,10 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { const { pathname } = useLocation(); + // We don't want so show the subnavs on the container root pages. + const showSourcesSubnav = pathname !== SOURCES_PATH && pathname !== PERSONAL_SOURCES_PATH; + const showGroupsSubnav = pathname !== GROUPS_PATH; + /** * Personal dashboard urls begin with /p/ * EX: http://localhost:5601/app/enterprise_search/workplace_search/p/sources @@ -45,6 +53,7 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { // TODO: Once auth is figured out, we need to have a check for the equivilent of `isAdmin`. const isOrganization = !pathname.match(personalSourceUrlRegex); + setContext(isOrganization); useEffect(() => { if (!hasInitialized) { @@ -53,10 +62,6 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { } }, [hasInitialized]); - useEffect(() => { - setContext(isOrganization); - }, [isOrganization]); - return ( @@ -65,19 +70,32 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { {errorConnecting ? : } + + } />} + restrictWidth + readOnlyMode={readOnlyMode} + > + + + + + } />} + restrictWidth + readOnlyMode={readOnlyMode} + > + + + } restrictWidth readOnlyMode={readOnlyMode}> {errorConnecting ? ( ) : ( - - - - - - - - + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx index d03c0abb441b98..3fddcf3b77fe43 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx @@ -12,7 +12,7 @@ import { EuiLink } from '@elastic/eui'; import { getContentSourcePath, SOURCES_PATH, - ORG_SOURCES_PATH, + PERSONAL_SOURCES_PATH, SOURCE_DETAILS_PATH, } from './routes'; @@ -26,13 +26,13 @@ describe('getContentSourcePath', () => { const wrapper = shallow(); const path = wrapper.find(EuiLink).prop('href'); - expect(path).toEqual(`${ORG_SOURCES_PATH}/123`); + expect(path).toEqual(`${SOURCES_PATH}/123`); }); it('should format user route', () => { const wrapper = shallow(); const path = wrapper.find(EuiLink).prop('href'); - expect(path).toEqual(`${SOURCES_PATH}/123`); + expect(path).toEqual(`${PERSONAL_SOURCES_PATH}/123`); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index be95c6ffe6f38b..3ec22ede888ab9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -7,6 +7,7 @@ import { generatePath } from 'react-router-dom'; import { CURRENT_MAJOR_VERSION } from '../../../common/version'; +import { ENT_SEARCH_DOCS_PREFIX } from '../shared/constants'; export const SETUP_GUIDE_PATH = '/setup_guide'; @@ -16,7 +17,6 @@ export const LEAVE_FEEDBACK_EMAIL = 'support@elastic.co'; export const LEAVE_FEEDBACK_URL = `mailto:${LEAVE_FEEDBACK_EMAIL}?Subject=Elastic%20Workplace%20Search%20Feedback`; export const DOCS_PREFIX = `https://www.elastic.co/guide/en/workplace-search/${CURRENT_MAJOR_VERSION}`; -export const ENT_SEARCH_DOCS_PREFIX = `https://www.elastic.co/guide/en/enterprise-search/${CURRENT_MAJOR_VERSION}`; export const DOCUMENT_PERMISSIONS_DOCS_URL = `${DOCS_PREFIX}/workplace-search-sources-document-permissions.html`; export const DOCUMENT_PERMISSIONS_SYNC_DOCS_URL = `${DOCUMENT_PERMISSIONS_DOCS_URL}#sources-permissions-synchronizing`; export const PRIVATE_SOURCES_DOCS_URL = `${DOCUMENT_PERMISSIONS_DOCS_URL}#sources-permissions-org-private`; @@ -44,21 +44,21 @@ export const CUSTOM_API_DOCS_URL = `${DOCS_PREFIX}/workplace-search-custom-sourc export const CUSTOM_API_DOCUMENT_PERMISSIONS_DOCS_URL = `${CUSTOM_SOURCE_DOCS_URL}#custom-api-source-document-level-access-control`; export const ENT_SEARCH_LICENSE_MANAGEMENT = `${ENT_SEARCH_DOCS_PREFIX}/license-management.html`; -export const ORG_PATH = '/org'; +export const PERSONAL_PATH = '/p'; -export const ROLE_MAPPINGS_PATH = `${ORG_PATH}/role-mappings`; +export const ROLE_MAPPINGS_PATH = '/role-mappings'; export const ROLE_MAPPING_PATH = `${ROLE_MAPPINGS_PATH}/:roleId`; export const ROLE_MAPPING_NEW_PATH = `${ROLE_MAPPINGS_PATH}/new`; -export const USERS_PATH = `${ORG_PATH}/users`; -export const SECURITY_PATH = `${ORG_PATH}/security`; +export const USERS_PATH = '/users'; +export const SECURITY_PATH = '/security'; export const GROUPS_PATH = '/groups'; export const GROUP_PATH = `${GROUPS_PATH}/:groupId`; export const GROUP_SOURCE_PRIORITIZATION_PATH = `${GROUPS_PATH}/:groupId/source_prioritization`; export const SOURCES_PATH = '/sources'; -export const ORG_SOURCES_PATH = `${ORG_PATH}${SOURCES_PATH}`; +export const PERSONAL_SOURCES_PATH = `${PERSONAL_PATH}${SOURCES_PATH}`; export const SOURCE_ADDED_PATH = `${SOURCES_PATH}/added`; export const ADD_SOURCE_PATH = `${SOURCES_PATH}/add`; @@ -81,7 +81,7 @@ export const ADD_SLACK_PATH = `${SOURCES_PATH}/add/slack`; export const ADD_ZENDESK_PATH = `${SOURCES_PATH}/add/zendesk`; export const ADD_CUSTOM_PATH = `${SOURCES_PATH}/add/custom`; -export const PERSONAL_SETTINGS_PATH = '/settings'; +export const PERSONAL_SETTINGS_PATH = `${PERSONAL_PATH}/settings`; export const SOURCE_DETAILS_PATH = `${SOURCES_PATH}/:sourceId`; export const SOURCE_CONTENT_PATH = `${SOURCES_PATH}/:sourceId/content`; @@ -93,7 +93,7 @@ export const REINDEX_JOB_PATH = `${SOURCES_PATH}/:sourceId/schema-errors/:active export const DISPLAY_SETTINGS_SEARCH_RESULT_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/`; export const DISPLAY_SETTINGS_RESULT_DETAIL_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/result-detail`; -export const ORG_SETTINGS_PATH = `${ORG_PATH}/settings`; +export const ORG_SETTINGS_PATH = '/settings'; export const ORG_SETTINGS_CUSTOMIZE_PATH = `${ORG_SETTINGS_PATH}/customize`; export const ORG_SETTINGS_CONNECTORS_PATH = `${ORG_SETTINGS_PATH}/connectors`; export const ORG_SETTINGS_OAUTH_APPLICATION_PATH = `${ORG_SETTINGS_PATH}/oauth`; @@ -120,9 +120,9 @@ export const getContentSourcePath = ( path: string, sourceId: string, isOrganization: boolean -): string => generatePath(isOrganization ? ORG_PATH + path : path, { sourceId }); -export const getGroupPath = (groupId: string) => generatePath(GROUP_PATH, { groupId }); -export const getGroupSourcePrioritizationPath = (groupId: string) => +): string => generatePath(isOrganization ? path : `${PERSONAL_PATH}${path}`, { sourceId }); +export const getGroupPath = (groupId: string): string => generatePath(GROUP_PATH, { groupId }); +export const getGroupSourcePrioritizationPath = (groupId: string): string => `${GROUPS_PATH}/${groupId}/source_prioritization`; -export const getSourcesPath = (path: string, isOrganization: boolean) => - isOrganization ? `${ORG_PATH}${path}` : path; +export const getSourcesPath = (path: string, isOrganization: boolean): string => + isOrganization ? path : `${PERSONAL_PATH}${path}`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx index 2bf5134e59e264..3e616a70031ac4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx @@ -17,7 +17,7 @@ import { EuiTitle, } from '@elastic/eui'; -import connectionIllustration from 'workplace_search/components/assets/connectionIllustration.svg'; +import connectionIllustration from '../../../../assets/connection_illustration.svg'; interface ConfigurationIntroProps { header: React.ReactNode; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx index a95d5ca75b0b69..fbd053f9b83743 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx @@ -13,6 +13,7 @@ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, + EuiPanel, EuiSpacer, EuiText, EuiTitle, @@ -57,7 +58,7 @@ export const ConfiguredSourcesList: React.FC = ({ {sources.map(({ name, serviceType, addPath, connected, accountContextOnly }, i) => ( -
+ = ({ )} -
+
))} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx index ad183181b4ecad..f9123ab4e1cca9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx @@ -240,13 +240,13 @@ export const ConnectInstance: React.FC = ({ gutterSize="xl" responsive={false} > - + {header} {featureBadgeGroup()} {descriptionBlock} {formFields} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_router.tsx new file mode 100644 index 00000000000000..5cebaad95e3a84 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_router.tsx @@ -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. + */ + +import React from 'react'; + +export const DisplaySettingsRouter: React.FC = () => <>Display Settings Placeholder; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/index.ts new file mode 100644 index 00000000000000..f8c6834db7805e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/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 { DisplaySettingsRouter } from './display_settings_router'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/index.ts new file mode 100644 index 00000000000000..720ae8ac2a7058 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/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 { Schema } from './schema'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx new file mode 100644 index 00000000000000..55f1e1e03b2db2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema.tsx @@ -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. + */ + +import React from 'react'; + +export const Schema: React.FC = () => <>Schema Placeholder; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx new file mode 100644 index 00000000000000..dd772b86a00e2e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_change_errors.tsx @@ -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. + */ + +import React from 'react'; + +export const SchemaChangeErrors: React.FC = () => <>Schema Errors Placeholder; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx new file mode 100644 index 00000000000000..cc68a62b9555d8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useValues } from 'kea'; + +import { AppLogic } from '../../../app_logic'; +import { NAV, CUSTOM_SERVICE_TYPE } from '../../../constants'; + +import { SourceLogic } from '../source_logic'; + +import { SideNavLink } from '../../../../shared/layout'; + +import { + getContentSourcePath, + SOURCE_DETAILS_PATH, + SOURCE_CONTENT_PATH, + SOURCE_SCHEMAS_PATH, + SOURCE_DISPLAY_SETTINGS_PATH, + SOURCE_SETTINGS_PATH, +} from '../../../routes'; + +export const SourceSubNav: React.FC = () => { + const { isOrganization } = useValues(AppLogic); + const { + contentSource: { id, serviceType }, + } = useValues(SourceLogic); + + if (!id) return null; + + const isCustom = serviceType === CUSTOM_SERVICE_TYPE; + + return ( + <> + + {NAV.OVERVIEW} + + + {NAV.CONTENT} + + {isCustom && ( + <> + + {NAV.SCHEMA} + + + {NAV.DISPLAY_SETTINGS} + + + )} + + {NAV.SETTINGS} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/index.ts new file mode 100644 index 00000000000000..f447751e965946 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Overview } from './components/overview'; +export { SourcesRouter } from './sources_router'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx new file mode 100644 index 00000000000000..ecc9c7d159131c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/organization_sources.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; +import { Link, Redirect } from 'react-router-dom'; + +import { EuiButton } from '@elastic/eui'; +import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; + +import { Loading } from '../../../shared/loading'; +import { ContentSection } from '../../components/shared/content_section'; +import { SourcesTable } from '../../components/shared/sources_table'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; + +import { SourcesLogic } from './sources_logic'; + +import { SourcesView } from './sources_view'; + +const ORG_LINK_TITLE = 'Add an organization content source'; +const ORG_PAGE_TITLE = 'Manage organization content sources'; +const ORG_PAGE_DESCRIPTION = + 'Organization sources are available to the entire organization and can be shared to specific user groups. By default, newly created organization sources are added to the Default group.'; +const ORG_HEADER_TITLE = 'Organization sources'; +const ORG_HEADER_DESCRIPTION = + 'Organization sources are available to the entire organization and can be assigned to specific user groups.'; + +export const OrganizationSources: React.FC = () => { + const { initializeSources, setSourceSearchability } = useActions(SourcesLogic); + + useEffect(() => { + initializeSources(); + }, []); + + const { dataLoading, contentSources } = useValues(SourcesLogic); + + if (dataLoading) return ; + + if (contentSources.length === 0) return ; + + const linkTitle = ORG_LINK_TITLE; + const headerTitle = ORG_HEADER_TITLE; + const headerDescription = ORG_HEADER_DESCRIPTION; + const sectionTitle = ''; + const sectionDescription = ''; + + return ( + + {/* TODO: Figure out with design how to make this look better w/o 2 ViewContentHeaders */} + + + + {linkTitle} + + + } + description={headerDescription} + alignItems="flexStart" + /> + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx new file mode 100644 index 00000000000000..f1818c852f97f4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.tsx @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; +import { Link } from 'react-router-dom'; + +import { EuiButton, EuiCallOut, EuiEmptyPrompt, EuiSpacer, EuiPanel } from '@elastic/eui'; + +import { LicensingLogic } from '../../../../applications/shared/licensing'; + +import { ADD_SOURCE_PATH } from '../../routes'; + +import noSharedSourcesIcon from '../../assets/share_circle.svg'; + +import { Loading } from '../../../shared/loading'; +import { ContentSection } from '../../components/shared/content_section'; +import { SourcesTable } from '../../components/shared/sources_table'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; + +import { AppLogic } from '../../app_logic'; +import { SourcesView } from './sources_view'; +import { SourcesLogic } from './sources_logic'; + +// TODO: Remove this after links in Kibana sidenav +interface SidebarLink { + title: string; + path?: string; + disabled?: boolean; + iconType?: string; + otherActivePath?: string; + dataTestSubj?: string; + onClick?(): void; +} + +const PRIVATE_LINK_TITLE = 'Add a private content source'; +const PRIVATE_CAN_CREATE_PAGE_TITLE = 'Manage private content sources'; +const PRIVATE_VIEW_ONLY_PAGE_TITLE = 'Review Group Sources'; +const PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION = + 'Review the status of all sources shared with your Group.'; +const PRIVATE_CAN_CREATE_PAGE_DESCRIPTION = + 'Review the status of all connected private sources, and manage private sources for your account.'; +const PRIVATE_HEADER_TITLE = 'My private content sources'; +const PRIVATE_HEADER_DESCRIPTION = 'Private content sources are available only to you.'; +const PRIVATE_SHARED_SOURCES_TITLE = 'Shared content sources'; + +export const PrivateSources: React.FC = () => { + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { initializeSources, setSourceSearchability, resetSourcesState } = useActions(SourcesLogic); + + useEffect(() => { + initializeSources(); + return resetSourcesState; + }, []); + + const { dataLoading, contentSources, serviceTypes, privateContentSources } = useValues( + SourcesLogic + ); + + const { + account: { canCreatePersonalSources, groups }, + } = useValues(AppLogic); + + if (dataLoading) return ; + + const sidebarLinks = [] as SidebarLink[]; + const hasConfiguredConnectors = serviceTypes.some(({ configured }) => configured); + const canAddSources = canCreatePersonalSources && hasConfiguredConnectors; + if (canAddSources) { + sidebarLinks.push({ + title: PRIVATE_LINK_TITLE, + iconType: 'plusInCircle', + path: ADD_SOURCE_PATH, + }); + } + + const headerAction = ( + + + {PRIVATE_LINK_TITLE} + + + ); + + const sourcesHeader = ( + + ); + + const privateSourcesTable = ( + + + + ); + + const privateSourcesEmptyState = ( + + + + You have no private sources} + body={ +

+ Select from the content sources below to create a private source, available only to + you +

+ } + /> + +
+
+ ); + + const sharedSourcesEmptyState = ( + + + + No content source available} + body={ +

+ Once content sources are shared with you, they will be displayed here, and available + via the search experience. +

+ } + /> + +
+
+ ); + + const hasPrivateSources = privateContentSources?.length > 0; + const privateSources = hasPrivateSources ? privateSourcesTable : privateSourcesEmptyState; + + const groupsSentence = `${groups.slice(0, groups.length - 1).join(', ')}, and ${groups.slice( + -1 + )}`; + + const sharedSources = ( + + + + ); + + const licenseCallout = ( + <> + +

Contact your search experience administrator for more information.

+
+ + + ); + + const PAGE_TITLE = canCreatePersonalSources + ? PRIVATE_CAN_CREATE_PAGE_TITLE + : PRIVATE_VIEW_ONLY_PAGE_TITLE; + const PAGE_DESCRIPTION = canCreatePersonalSources + ? PRIVATE_CAN_CREATE_PAGE_DESCRIPTION + : PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION; + + const pageHeader = ; + + return ( + + {/* TODO: Figure out with design how to make this look better w/o 2 ViewContentHeaders */} + {pageHeader} + {hasPrivateSources && !hasPlatinumLicense && licenseCallout} + {canAddSources && sourcesHeader} + {canCreatePersonalSources && privateSources} + {contentSources.length > 0 ? sharedSources : sharedSourcesEmptyState} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts index 0a11da02dc7897..51b5735f010451 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts @@ -146,6 +146,7 @@ interface PreContentSourceResponse { } export const SourceLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'source_logic'], actions: { onInitializeSource: (contentSource: ContentSourceFullData) => contentSource, onUpdateSourceName: (name: string) => name, @@ -601,7 +602,7 @@ export const SourceLogic = kea>({ try { const response = await HttpLogic.values.http.post(route, { - body: JSON.stringify({ params }), + body: JSON.stringify({ ...params }), }); actions.setCustomSourceData(response); successCallback(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx new file mode 100644 index 00000000000000..7161e613247cde --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { History } from 'history'; +import { useActions, useValues } from 'kea'; +import moment from 'moment'; +import { Route, Switch, useHistory, useParams } from 'react-router-dom'; + +import { EuiButton, EuiCallOut, EuiSpacer } from '@elastic/eui'; + +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; + +import { NAV } from '../../constants'; + +import { + ENT_SEARCH_LICENSE_MANAGEMENT, + REINDEX_JOB_PATH, + SOURCE_DETAILS_PATH, + SOURCE_CONTENT_PATH, + SOURCE_SCHEMAS_PATH, + SOURCE_DISPLAY_SETTINGS_PATH, + SOURCE_SETTINGS_PATH, + getContentSourcePath as sourcePath, + getSourcesPath, +} from '../../routes'; + +import { AppLogic } from '../../app_logic'; + +import { Loading } from '../../../shared/loading'; + +import { CUSTOM_SERVICE_TYPE } from '../../constants'; +import { SourceLogic } from './source_logic'; + +import { DisplaySettingsRouter } from './components/display_settings'; +import { Overview } from './components/overview'; +import { Schema } from './components/schema'; +import { SchemaChangeErrors } from './components/schema/schema_change_errors'; +import { SourceContent } from './components/source_content'; +import { SourceInfoCard } from './components/source_info_card'; +import { SourceSettings } from './components/source_settings'; + +export const SourceRouter: React.FC = () => { + const history = useHistory() as History; + const { sourceId } = useParams() as { sourceId: string }; + const { initializeSource } = useActions(SourceLogic); + const { contentSource, dataLoading } = useValues(SourceLogic); + const { isOrganization } = useValues(AppLogic); + + useEffect(() => { + initializeSource(sourceId, history); + }, []); + + if (dataLoading) return ; + + const { + name, + createdAt, + serviceType, + serviceName, + isFederatedSource, + supportedByLicense, + } = contentSource; + const isCustomSource = serviceType === CUSTOM_SERVICE_TYPE; + + const pageHeader = ( +
+ + {name} + + + +
+ ); + + const callout = ( + <> + +

+ Your organization's license level changed and no longer supports document-level + permissions.{' '} +

+

Don't worry: your data is safe. Search has been disabled.

+

Upgrade to a Platinum license to re-enable this source.

+ Explore Platinum license +
+ + + ); + + return ( + <> + {!supportedByLicense && callout} + {/* TODO: Figure out with design how to make this look better */} + {pageHeader} + + + + + + + + + + + + {isCustomSource && ( + + + + + + )} + {isCustomSource && ( + + + + + + )} + {isCustomSource && ( + + + + + + )} + + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources.scss new file mode 100644 index 00000000000000..fb0cecc1814870 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources.scss @@ -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. + */ + +.source-grid-configured { + + .source-card-configured { + padding: 8px; + + &__icon { + width: 2em; + height: 2em; + } + + &__not-connected-tooltip { + position: relative; + top: 3px; + left: 4px; + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts index 5a8da7cd32fa8c..1757f2a6414f74 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -24,9 +24,6 @@ import { staticSourceData } from './source_data'; import { AppLogic } from '../../app_logic'; -const ORG_SOURCES_PATH = '/api/workplace_search/org/sources'; -const ACCOUNT_SOURCES_PATH = '/api/workplace_search/account/sources'; - interface ServerStatuses { [key: string]: string; } @@ -81,6 +78,7 @@ interface ISourcesServerResponse { } export const SourcesLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'sources_logic'], actions: { setServerSourceStatuses: (statuses: ContentSourceStatus[]) => statuses, onInitializeSources: (serverResponse: ISourcesServerResponse) => serverResponse, @@ -165,7 +163,9 @@ export const SourcesLogic = kea>( listeners: ({ actions, values }) => ({ initializeSources: async () => { const { isOrganization } = AppLogic.values; - const route = isOrganization ? ORG_SOURCES_PATH : ACCOUNT_SOURCES_PATH; + const route = isOrganization + ? '/api/workplace_search/org/sources' + : '/api/workplace_search/account/sources'; try { const response = await HttpLogic.values.http.get(route); @@ -239,7 +239,9 @@ export const SourcesLogic = kea>( }); const fetchSourceStatuses = async (isOrganization: boolean) => { - const route = isOrganization ? ORG_SOURCES_PATH : ACCOUNT_SOURCES_PATH; + const route = isOrganization + ? '/api/workplace_search/org/sources/status' + : '/api/workplace_search/account/sources/status'; let response; try { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx new file mode 100644 index 00000000000000..9f96a13e272d2a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { Location } from 'history'; +import { useActions, useValues } from 'kea'; +import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; + +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; + +import { LicensingLogic } from '../../../../applications/shared/licensing'; + +import { NAV } from '../../constants'; +import { + ADD_SOURCE_PATH, + SOURCE_ADDED_PATH, + SOURCE_DETAILS_PATH, + PERSONAL_SOURCES_PATH, + SOURCES_PATH, + getSourcesPath, +} from '../../routes'; + +import { FlashMessages } from '../../../shared/flash_messages'; + +import { AppLogic } from '../../app_logic'; +import { staticSourceData } from './source_data'; +import { SourcesLogic } from './sources_logic'; + +import { AddSource, AddSourceList } from './components/add_source'; +import { SourceAdded } from './components/source_added'; +import { OrganizationSources } from './organization_sources'; +import { PrivateSources } from './private_sources'; +import { SourceRouter } from './source_router'; + +import './sources.scss'; + +export const SourcesRouter: React.FC = () => { + const { pathname } = useLocation() as Location; + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { resetSourcesState } = useActions(SourcesLogic); + const { + account: { canCreatePersonalSources }, + isOrganization, + } = useValues(AppLogic); + + /** + * React router is not triggering the useEffect callback function in Sources when child links are clicked so this + * is needed to ensure that the sources state is reset whenever the app changes routes. + */ + useEffect(() => { + resetSourcesState(); + }, [pathname]); + + return ( + <> + + + + + + + + + + + + + {staticSourceData.map(({ addPath, accountContextOnly, name }, i) => ( + + + {!hasPlatinumLicense && accountContextOnly ? ( + + ) : ( + + )} + + ))} + {staticSourceData.map(({ addPath, name }, i) => ( + + + + + ))} + {staticSourceData.map(({ addPath, name }, i) => ( + + + + + ))} + {staticSourceData.map(({ addPath, name, configuration: { needsConfiguration } }, i) => { + if (needsConfiguration) + return ( + + + + + ); + })} + {canCreatePersonalSources ? ( + + + + + + ) : ( + + )} + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx new file mode 100644 index 00000000000000..7485f986076d71 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiText, +} from '@elastic/eui'; + +import { FlashMessagesLogic } from '../../../shared/flash_messages'; + +import { Loading } from '../../../shared/loading'; +import { SourceIcon } from '../../components/shared/source_icon'; + +import { EXTERNAL_IDENTITIES_DOCS_URL, DOCUMENT_PERMISSIONS_DOCS_URL } from '../../routes'; + +import { SourcesLogic } from './sources_logic'; + +const POLLING_INTERVAL = 10000; + +interface SourcesViewProps { + children: React.ReactNode; +} + +export const SourcesView: React.FC = ({ children }) => { + const { initializeSources, pollForSourceStatusChanges, resetPermissionsModal } = useActions( + SourcesLogic + ); + + const { dataLoading, permissionsModal } = useValues(SourcesLogic); + + useEffect(() => { + initializeSources(); + const pollingInterval = window.setInterval(pollForSourceStatusChanges, POLLING_INTERVAL); + + return () => { + FlashMessagesLogic.actions.clearFlashMessages(); + clearInterval(pollingInterval); + }; + }, []); + + if (dataLoading) return ; + + const PermissionsModal = ({ + addedSourceName, + serviceType, + }: { + addedSourceName: string; + serviceType: string; + }) => ( + + + + + + + + + {addedSourceName} requires additional configuration + + + + + +

+ {addedSourceName} has been successfully connected and initial content synchronization + is already underway. Since you have elected to synchronize document-level permission + information, you must now provide user and group mappings using the  + + External Identities API + + . +

+ +

+ Documents will not be searchable from Workplace Search until user and group mappings + have been configured.  + + Learn more about document-level permission configuration + + . +

+
+
+ + + I understand + + +
+
+ ); + + return ( + <> + {!!permissionsModal && permissionsModal.additionalConfiguration && ( + + )} + {children} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx index c0f8bf57989ca9..cbfb22915c4eb6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx @@ -29,7 +29,7 @@ import { import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { Group } from '../../../types'; -import { ORG_SOURCES_PATH } from '../../../routes'; +import { SOURCES_PATH } from '../../../routes'; import noSharedSourcesIcon from '../../../assets/share_circle.svg'; @@ -96,7 +96,7 @@ export const GroupManagerModal: React.FC = ({ const handleSelectAll = () => selectAll(allSelected ? [] : allItems); const sourcesButton = ( - + {ADD_SOURCE_BUTTON_TEXT} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx index 268e4f8da445a5..64dc5149decd59 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx @@ -11,7 +11,7 @@ import { setMockValues } from './__mocks__'; import React from 'react'; import { shallow } from 'enzyme'; -import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; +import { SOURCES_PATH, USERS_PATH } from '../../routes'; import { OnboardingSteps, OrgNameOnboarding } from './onboarding_steps'; import { OnboardingCard } from './onboarding_card'; @@ -32,7 +32,7 @@ describe('OnboardingSteps', () => { const wrapper = shallow(); expect(wrapper.find(OnboardingCard)).toHaveLength(1); - expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(ORG_SOURCES_PATH); + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(SOURCES_PATH); expect(wrapper.find(OnboardingCard).prop('description')).toBe( 'Add shared sources for your organization to start searching.' ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx index ed5136a6f7a4e2..4957324aa6bd79 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx @@ -24,7 +24,7 @@ import { import sharedSourcesIcon from '../../components/shared/assets/source_icons/share_circle.svg'; import { TelemetryLogic } from '../../../shared/telemetry'; import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; -import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; +import { SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; import { ContentSection } from '../../components/shared/content_section'; @@ -75,7 +75,7 @@ export const OnboardingSteps: React.FC = () => { const accountsPath = !isFederatedAuth && (canCreateInvitations || isCurated) ? USERS_PATH : undefined; - const sourcesPath = canCreateContentSources || isCurated ? ORG_SOURCES_PATH : undefined; + const sourcesPath = canCreateContentSources || isCurated ? SOURCES_PATH : undefined; const SOURCES_CARD_DESCRIPTION = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.description', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx index 6614ac58b0744a..06c620ad384e62 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx @@ -12,7 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { ContentSection } from '../../components/shared/content_section'; -import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; +import { SOURCES_PATH, USERS_PATH } from '../../routes'; import { AppLogic } from '../../app_logic'; import { OverviewLogic } from './overview_logic'; @@ -43,7 +43,7 @@ export const OrganizationStats: React.FC = () => { { defaultMessage: 'Shared sources' } )} count={sourcesCount} - actionPath={ORG_SOURCES_PATH} + actionPath={SOURCES_PATH} /> {!isFederatedAuth && ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.test.tsx index 73cf4b419f9449..49f0156ce481d6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; -import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetupGuideLayout } from '../../../shared/setup_guide'; import { SetupGuide } from './'; describe('SetupGuide', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx index 3d6d65fce2528a..3b91c4e84d02f7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx @@ -10,13 +10,14 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; -import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetupGuideLayout, SETUP_GUIDE_TITLE } from '../../../shared/setup_guide'; import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; + import GettingStarted from './assets/getting_started.png'; -const GETTING_STARTED_LINK_URL = - 'https://www.elastic.co/guide/en/workplace-search/current/workplace-search-getting-started.html'; +import { DOCS_PREFIX } from '../../routes'; +const GETTING_STARTED_LINK_URL = `${DOCS_PREFIX}/workplace-search-getting-started.html`; export const SetupGuide: React.FC = () => { return ( @@ -26,13 +27,7 @@ export const SetupGuide: React.FC = () => { standardAuthLink="https://www.elastic.co/guide/en/workplace-search/current/workplace-search-security.html#standard" elasticsearchNativeAuthLink="https://www.elastic.co/guide/en/workplace-search/current/workplace-search-security.html#elasticsearch-native-realm" > - +
diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index f4ee8283122fda..94e9ea88ea755e 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -16,6 +16,7 @@ import { FeatureCatalogueCategory, HomePublicPluginSetup, } from '../../../../src/plugins/home/public'; +import { CloudSetup } from '../../cloud/public'; import { LicensingPluginStart } from '../../licensing/public'; import { @@ -34,9 +35,11 @@ export interface ClientData extends InitialAppData { } interface PluginsSetup { + cloud?: CloudSetup; home?: HomePublicPluginSetup; } export interface PluginsStart { + cloud?: CloudSetup; licensing: LicensingPluginStart; } @@ -50,6 +53,8 @@ export class EnterpriseSearchPlugin implements Plugin { } public setup(core: CoreSetup, plugins: PluginsSetup) { + const { cloud } = plugins; + core.application.register({ id: ENTERPRISE_SEARCH_PLUGIN.ID, title: ENTERPRISE_SEARCH_PLUGIN.NAV_TITLE, @@ -57,7 +62,7 @@ export class EnterpriseSearchPlugin implements Plugin { appRoute: ENTERPRISE_SEARCH_PLUGIN.URL, category: DEFAULT_APP_CATEGORIES.enterpriseSearch, mount: async (params: AppMountParameters) => { - const kibanaDeps = await this.getKibanaDeps(core, params); + const kibanaDeps = await this.getKibanaDeps(core, params, cloud); const { chrome, http } = kibanaDeps.core; chrome.docTitle.change(ENTERPRISE_SEARCH_PLUGIN.NAME); @@ -78,7 +83,7 @@ export class EnterpriseSearchPlugin implements Plugin { appRoute: APP_SEARCH_PLUGIN.URL, category: DEFAULT_APP_CATEGORIES.enterpriseSearch, mount: async (params: AppMountParameters) => { - const kibanaDeps = await this.getKibanaDeps(core, params); + const kibanaDeps = await this.getKibanaDeps(core, params, cloud); const { chrome, http } = kibanaDeps.core; chrome.docTitle.change(APP_SEARCH_PLUGIN.NAME); @@ -99,7 +104,7 @@ export class EnterpriseSearchPlugin implements Plugin { appRoute: WORKPLACE_SEARCH_PLUGIN.URL, category: DEFAULT_APP_CATEGORIES.enterpriseSearch, mount: async (params: AppMountParameters) => { - const kibanaDeps = await this.getKibanaDeps(core, params); + const kibanaDeps = await this.getKibanaDeps(core, params, cloud); const { chrome, http } = kibanaDeps.core; chrome.docTitle.change(WORKPLACE_SEARCH_PLUGIN.NAME); @@ -150,11 +155,13 @@ export class EnterpriseSearchPlugin implements Plugin { public stop() {} - private async getKibanaDeps(core: CoreSetup, params: AppMountParameters) { + private async getKibanaDeps(core: CoreSetup, params: AppMountParameters, cloud?: CloudSetup) { // Helper for using start dependencies on mount (instead of setup dependencies) // and for grouping Kibana-related args together (vs. plugin-specific args) const [coreStart, pluginsStart] = await core.getStartServices(); - return { params, core: coreStart, plugins: pluginsStart as PluginsStart }; + const plugins = { ...pluginsStart, cloud } as PluginsStart; + + return { params, core: coreStart, plugins }; } private getPluginData() { diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts index 9cf491b79fd24e..22e2deaace1dce 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts @@ -328,10 +328,8 @@ describe('sources routes', () => { const mockRequest = { params: { id: '123' }, body: { - query: { - content_source: { - name: 'foo', - }, + content_source: { + name: 'foo', }, }, }; @@ -406,7 +404,7 @@ describe('sources routes', () => { mockRouter.callRoute(mockRequest); expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/ws/pre_content_sources/zendesk', + path: '/ws/sources/zendesk/prepare', }); }); }); @@ -732,10 +730,8 @@ describe('sources routes', () => { const mockRequest = { params: { id: '123' }, body: { - query: { - content_source: { - name: 'foo', - }, + content_source: { + name: 'foo', }, }, }; @@ -810,7 +806,7 @@ describe('sources routes', () => { mockRouter.callRoute(mockRequest); expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/ws/org/pre_content_sources/zendesk', + path: '/ws/org/sources/zendesk/prepare', }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index bdd048438dae53..24473388c03b11 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -200,10 +200,8 @@ export function registerAccountSourceSettingsRoute({ path: '/api/workplace_search/account/sources/{id}/settings', validate: { body: schema.object({ - query: schema.object({ - content_source: schema.object({ - name: schema.string(), - }), + content_source: schema.object({ + name: schema.string(), }), }), params: schema.object({ @@ -256,7 +254,7 @@ export function registerAccountPrepareSourcesRoute({ }, async (context, request, response) => { return enterpriseSearchRequestHandler.createRequest({ - path: `/ws/pre_content_sources/${request.params.service_type}`, + path: `/ws/sources/${request.params.service_type}/prepare`, })(context, request, response); } ); @@ -372,7 +370,7 @@ export function registerOrgCreateSourceRoute({ login: schema.maybe(schema.string()), password: schema.maybe(schema.string()), organizations: schema.maybe(schema.arrayOf(schema.string())), - indexPermissions: schema.boolean(), + indexPermissions: schema.maybe(schema.boolean()), }), }, }, @@ -462,10 +460,8 @@ export function registerOrgSourceSettingsRoute({ path: '/api/workplace_search/org/sources/{id}/settings', validate: { body: schema.object({ - query: schema.object({ - content_source: schema.object({ - name: schema.string(), - }), + content_source: schema.object({ + name: schema.string(), }), }), params: schema.object({ @@ -518,7 +514,7 @@ export function registerOrgPrepareSourcesRoute({ }, async (context, request, response) => { return enterpriseSearchRequestHandler.createRequest({ - path: `/ws/org/pre_content_sources/${request.params.service_type}`, + path: `/ws/org/sources/${request.params.service_type}/prepare`, })(context, request, response); } ); diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index f35b6b3f7de6ad..4af3f3beb32bec 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -140,6 +140,8 @@ export const agentRouteService = { getBulkUpgradePath: () => AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, getListPath: () => AGENT_API_ROUTES.LIST_PATTERN, getStatusPath: () => AGENT_API_ROUTES.STATUS_PATTERN, + getCreateActionPath: (agentId: string) => + AGENT_API_ROUTES.ACTIONS_PATTERN.replace('{agentId}', agentId), }; export const outputRoutesService = { diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/index.ts b/x-pack/plugins/fleet/public/applications/fleet/components/index.ts index 93bc0645c7eee9..ea6abc4bba5f56 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/index.ts @@ -11,3 +11,4 @@ export { PackageIcon } from './package_icon'; export { ContextMenuActions } from './context_menu_actions'; export { SearchBar } from './search_bar'; export * from './settings_flyout'; +export * from './link_and_revision'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/link_and_revision.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/link_and_revision.tsx new file mode 100644 index 00000000000000..a9e44b200cf698 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/components/link_and_revision.tsx @@ -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 { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { CSSProperties, memo } from 'react'; +import { EuiLinkProps } from '@elastic/eui/src/components/link/link'; + +const MIN_WIDTH: CSSProperties = { minWidth: 0 }; +const NO_WRAP_WHITE_SPACE: CSSProperties = { whiteSpace: 'nowrap' }; + +export type LinkAndRevisionProps = EuiLinkProps & { + revision?: string | number; +}; + +/** + * Components shows a link for a given value along with a revision number to its right. The display + * value is truncated if it is longer than the width of where it is displayed, while the revision + * always remain visible + */ +export const LinkAndRevision = memo( + ({ revision, className, ...euiLinkProps }) => { + return ( + + + + + {revision && ( + + + + + + )} + + ); + } +); diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts index 6026a5579f65b6..5b0243f127333f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts @@ -13,7 +13,8 @@ export { useBreadcrumbs } from './use_breadcrumbs'; export { useLink } from './use_link'; export { useKibanaLink } from './use_kibana_link'; export { usePackageIconType, UsePackageIconType } from './use_package_icon_type'; -export { usePagination, Pagination } from './use_pagination'; +export { usePagination, Pagination, PAGE_SIZE_OPTIONS } from './use_pagination'; +export { useUrlPagination } from './use_url_pagination'; export { useSorting } from './use_sorting'; export { useDebounce } from './use_debounce'; export * from './use_request'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_pagination.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_pagination.tsx index 699bba3c62f97a..1fdd223ef80471 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_pagination.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_pagination.tsx @@ -4,22 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; + +export const PAGE_SIZE_OPTIONS: readonly number[] = [5, 20, 50]; export interface Pagination { currentPage: number; pageSize: number; } -export function usePagination() { - const [pagination, setPagination] = useState({ +export function usePagination( + pageInfo: Pagination = { currentPage: 1, pageSize: 20, - }); + } +) { + const [pagination, setPagination] = useState(pageInfo); + const pageSizeOptions = useMemo(() => [...PAGE_SIZE_OPTIONS], []); return { pagination, setPagination, - pageSizeOptions: [5, 20, 50], + pageSizeOptions, }; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts index 564e7b225cf455..7bbf621c578941 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts @@ -26,6 +26,8 @@ import { PostBulkAgentUpgradeRequest, PostAgentUpgradeResponse, PostBulkAgentUpgradeResponse, + PostNewAgentActionRequest, + PostNewAgentActionResponse, } from '../../types'; type RequestOptions = Pick, 'pollIntervalMs'>; @@ -144,6 +146,19 @@ export function sendPostAgentUpgrade( }); } +export function sendPostAgentAction( + agentId: string, + body: PostNewAgentActionRequest['body'], + options?: RequestOptions +) { + return sendRequest({ + path: agentRouteService.getCreateActionPath(agentId), + method: 'post', + body, + ...options, + }); +} + export function sendPostBulkAgentUpgrade( body: PostBulkAgentUpgradeRequest['body'], options?: RequestOptions diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_url_pagination.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_url_pagination.ts new file mode 100644 index 00000000000000..f9c351899fe0a2 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_url_pagination.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useEffect, useMemo } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import { useUrlParams } from './use_url_params'; +import { PAGE_SIZE_OPTIONS, Pagination, usePagination } from './use_pagination'; + +type SetUrlPagination = (pagination: Pagination) => void; +interface UrlPagination { + pagination: Pagination; + setPagination: SetUrlPagination; + pageSizeOptions: number[]; +} + +type UrlPaginationParams = Partial; + +/** + * Uses URL params for pagination and also persists those to the URL as they are updated + */ +export const useUrlPagination = (): UrlPagination => { + const location = useLocation(); + const history = useHistory(); + const { urlParams, toUrlParams } = useUrlParams(); + const urlPaginationParams = useMemo(() => { + return paginationFromUrlParams(urlParams); + }, [urlParams]); + const { pagination, pageSizeOptions, setPagination } = usePagination(urlPaginationParams); + + const setUrlPagination = useCallback( + ({ pageSize, currentPage }) => { + history.push({ + ...location, + search: toUrlParams({ + ...urlParams, + currentPage, + pageSize, + }), + }); + }, + [history, location, toUrlParams, urlParams] + ); + + useEffect(() => { + setPagination((prevState) => { + return { + ...prevState, + ...paginationFromUrlParams(urlParams), + }; + }); + }, [setPagination, urlParams]); + + return { + pagination, + setPagination: setUrlPagination, + pageSizeOptions, + }; +}; + +const paginationFromUrlParams = (urlParams: UrlPaginationParams): Pagination => { + const pagination: Pagination = { + pageSize: 20, + currentPage: 1, + }; + + // Search params can appear multiple times in the URL, in which case the value for them, + // once parsed, would be an array. In these case, we take the last value defined + pagination.currentPage = Number( + (Array.isArray(urlParams.currentPage) + ? urlParams.currentPage[urlParams.currentPage.length - 1] + : urlParams.currentPage) ?? pagination.currentPage + ); + pagination.pageSize = + Number( + (Array.isArray(urlParams.pageSize) + ? urlParams.pageSize[urlParams.pageSize.length - 1] + : urlParams.pageSize) ?? pagination.pageSize + ) ?? pagination.pageSize; + + // If Current Page is not a valid positive integer, set it to 1 + if (!Number.isFinite(pagination.currentPage) || pagination.currentPage < 1) { + pagination.currentPage = 1; + } + + // if pageSize is not one of the expected page sizes, reset it to 20 (default) + if (!PAGE_SIZE_OPTIONS.includes(pagination.pageSize)) { + pagination.pageSize = 20; + } + + return pagination; +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx index 62792b84105abb..a68dbe52555ffe 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx @@ -77,15 +77,13 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { const [packageInfo, setPackageInfo] = useState(); const [isLoadingSecondStep, setIsLoadingSecondStep] = useState(false); - const agentPolicyId = agentPolicy?.id; // Retrieve agent count + const agentPolicyId = useMemo(() => agentPolicy?.id, [agentPolicy?.id]); useEffect(() => { const getAgentCount = async () => { - if (agentPolicyId) { - const { data } = await sendGetAgentStatus({ policyId: agentPolicyId }); - if (data?.results.total) { - setAgentCount(data.results.total); - } + const { data } = await sendGetAgentStatus({ policyId: agentPolicyId }); + if (data?.results.total !== undefined) { + setAgentCount(data.results.total); } }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx index 9c94bb939cdf8e..53463c14b9ce67 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -91,15 +91,13 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ sortOrder: 'asc', full: true, }); - // eslint-disable-next-line react-hooks/exhaustive-deps - const agentPolicies = agentPoliciesData?.items || []; - const agentPoliciesById = agentPolicies.reduce( - (acc: { [key: string]: GetAgentPoliciesResponseItem }, policy) => { + const agentPolicies = useMemo(() => agentPoliciesData?.items || [], [agentPoliciesData?.items]); + const agentPoliciesById = useMemo(() => { + return agentPolicies.reduce((acc: { [key: string]: GetAgentPoliciesResponseItem }, policy) => { acc[policy.id] = policy; return acc; - }, - {} - ); + }, {}); + }, [agentPolicies]); // Update parent package state useEffect(() => { @@ -132,21 +130,24 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ } }, [selectedPolicyId, agentPolicy, updateAgentPolicy, setIsLoadingSecondStep]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const agentPolicyOptions: Array> = packageInfoData - ? agentPolicies.map((agentConf) => { - const alreadyHasLimitedPackage = - (isLimitedPackage && - doesAgentPolicyAlreadyIncludePackage(agentConf, packageInfoData.response.name)) || - false; - return { - label: agentConf.name, - value: agentConf.id, - disabled: alreadyHasLimitedPackage, - 'data-test-subj': 'agentPolicyItem', - }; - }) - : []; + const agentPolicyOptions: Array> = useMemo( + () => + packageInfoData + ? agentPolicies.map((agentConf) => { + const alreadyHasLimitedPackage = + (isLimitedPackage && + doesAgentPolicyAlreadyIncludePackage(agentConf, packageInfoData.response.name)) || + false; + return { + label: agentConf.name, + value: agentConf.id, + disabled: alreadyHasLimitedPackage, + 'data-test-subj': 'agentPolicyItem', + }; + }) + : [], + [agentPolicies, isLimitedPackage, packageInfoData] + ); const selectedAgentPolicyOption = agentPolicyOptions.find( (option) => option.value === selectedPolicyId @@ -246,7 +247,7 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ id="xpack.fleet.createPackagePolicy.StepSelectPolicy.agentPolicyAgentsDescriptionText" defaultMessage="{count, plural, one {# agent} other {# agents}} are enrolled with the selected agent policy." values={{ - count: agentPoliciesById[selectedPolicyId].agents || 0, + count: agentPoliciesById[selectedPolicyId]?.agents ?? 0, }} /> ) : null @@ -282,7 +283,7 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ id="xpack.fleet.createPackagePolicy.StepSelectPolicy.agentPolicyAgentsCountText" defaultMessage="{count, plural, one {# agent} other {# agents}} enrolled" values={{ - count: agentPoliciesById[option.value!].agents || 0, + count: agentPoliciesById[option.value!]?.agents ?? 0, }} /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx index 8c2fe838bfa435..0e8bb6b49e4abd 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx @@ -34,7 +34,7 @@ import { useUrlParams, useBreadcrumbs, } from '../../../hooks'; -import { SearchBar } from '../../../components'; +import { LinkAndRevision, SearchBar } from '../../../components'; import { LinkedAgentCount, AgentPolicyActionMenu } from '../components'; import { CreateAgentPolicyFlyout } from './components'; @@ -129,26 +129,13 @@ export const AgentPolicyListPage: React.FunctionComponent<{}> = () => { }), width: '20%', render: (name: string, agentPolicy: AgentPolicy) => ( - - - - {name || agentPolicy.id} - - - - - - - - + + {name || agentPolicy.id} + ), }, { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details.tsx index 5ce757734e6370..1b6ad35cc64242 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details.tsx @@ -109,6 +109,17 @@ export const AgentDetailsContent: React.FunctionComponent<{ : 'stable' : '-', }, + { + title: i18n.translate('xpack.fleet.agentDetails.logLevel', { + defaultMessage: 'Log level', + }), + description: + typeof agent.local_metadata.elastic === 'object' && + typeof agent.local_metadata.elastic.agent === 'object' && + typeof agent.local_metadata.elastic.agent.log_level === 'string' + ? agent.local_metadata.elastic.agent.log_level + : '-', + }, { title: i18n.translate('xpack.fleet.agentDetails.platformLabel', { defaultMessage: 'Platform', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx index b56e27356ef342..41069e7107862a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx @@ -24,3 +24,12 @@ export const DEFAULT_DATE_RANGE = { start: 'now-1d', end: 'now', }; + +export const AGENT_LOG_LEVELS = { + ERROR: 'error', + WARNING: 'warning', + INFO: 'info', + DEBUG: 'debug', +}; + +export const DEFAULT_LOG_LEVEL = AGENT_LOG_LEVELS.INFO; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx index b034168dc8a15e..a45831b2bbd2ad 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx @@ -6,8 +6,10 @@ import React, { memo, useState, useEffect } from 'react'; import { EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { AGENT_LOG_LEVELS, AGENT_LOG_INDEX_PATTERN, LOG_LEVEL_FIELD } from './constants'; import { useStartServices } from '../../../../../hooks'; -import { AGENT_LOG_INDEX_PATTERN, LOG_LEVEL_FIELD } from './constants'; + +const LEVEL_VALUES = Object.values(AGENT_LOG_LEVELS); export const LogLevelFilter: React.FunctionComponent<{ selectedLevels: string[]; @@ -16,13 +18,13 @@ export const LogLevelFilter: React.FunctionComponent<{ const { data } = useStartServices(); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [levelValues, setLevelValues] = useState([]); + const [levelValues, setLevelValues] = useState(LEVEL_VALUES); useEffect(() => { const fetchValues = async () => { setIsLoading(true); try { - const values = await data.autocomplete.getValueSuggestions({ + const values: string[] = await data.autocomplete.getValueSuggestions({ indexPattern: { title: AGENT_LOG_INDEX_PATTERN, fields: [LOG_LEVEL_FIELD], @@ -30,7 +32,7 @@ export const LogLevelFilter: React.FunctionComponent<{ field: LOG_LEVEL_FIELD, query: '', }); - setLevelValues(values.sort()); + setLevelValues([...new Set([...LEVEL_VALUES, ...values.sort()])]); } catch (e) { setLevelValues([]); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx index e033781a850a02..bed857c0730997 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx @@ -17,6 +17,8 @@ import { EuiButtonEmpty, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import semverGte from 'semver/functions/gte'; +import semverCoerce from 'semver/functions/coerce'; import { RedirectAppLinks } from '../../../../../../../../../../../src/plugins/kibana_react/public'; import { TimeRange, esKuery } from '../../../../../../../../../../../src/plugins/data/public'; import { LogStream } from '../../../../../../../../../infra/public'; @@ -27,6 +29,7 @@ import { DatasetFilter } from './filter_dataset'; import { LogLevelFilter } from './filter_log_level'; import { LogQueryBar } from './query_bar'; import { buildQuery } from './build_query'; +import { SelectLogLevel } from './select_log_level'; const WrapperFlexGroup = styled(EuiFlexGroup)` height: 100%; @@ -137,6 +140,18 @@ export const AgentLogs: React.FunctionComponent<{ agent: Agent }> = memo(({ agen [logStreamQuery, dateRange.endExpression, dateRange.startExpression, http.basePath] ); + const agentVersion = agent.local_metadata?.elastic?.agent?.version; + const isLogLevelSelectionAvailable = useMemo(() => { + if (!agentVersion) { + return false; + } + const agentVersionWithPrerelease = semverCoerce(agentVersion)?.version; + if (!agentVersionWithPrerelease) { + return false; + } + return semverGte(agentVersionWithPrerelease, '7.11.0'); + }, [agentVersion]); + return ( @@ -213,6 +228,11 @@ export const AgentLogs: React.FunctionComponent<{ agent: Agent }> = memo(({ agen /> + {isLogLevelSelectionAvailable && ( + + + + )} ); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/select_log_level.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/select_log_level.tsx new file mode 100644 index 00000000000000..7879c969d644a8 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/select_log_level.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSelect, EuiFormLabel, EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { Agent } from '../../../../../types'; +import { sendPostAgentAction, useStartServices } from '../../../../../hooks'; +import { AGENT_LOG_LEVELS, DEFAULT_LOG_LEVEL } from './constants'; + +const LEVEL_VALUES = Object.values(AGENT_LOG_LEVELS); + +export const SelectLogLevel: React.FC<{ agent: Agent }> = memo(({ agent }) => { + const { notifications } = useStartServices(); + const [isLoading, setIsLoading] = useState(false); + const [agentLogLevel, setAgentLogLevel] = useState( + agent.local_metadata?.elastic?.agent?.log_level ?? DEFAULT_LOG_LEVEL + ); + const [selectedLogLevel, setSelectedLogLevel] = useState(agentLogLevel); + + const onClickApply = useCallback(() => { + setIsLoading(true); + async function send() { + try { + const res = await sendPostAgentAction(agent.id, { + action: { + type: 'SETTINGS', + data: { + log_level: selectedLogLevel, + }, + }, + }); + if (res.error) { + throw res.error; + } + setAgentLogLevel(selectedLogLevel); + notifications.toasts.addSuccess( + i18n.translate('xpack.fleet.agentLogs.selectLogLevel.successText', { + defaultMessage: `Changed agent logging level to '{logLevel}'.`, + values: { + logLevel: selectedLogLevel, + }, + }) + ); + } catch (error) { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.agentLogs.selectLogLevel.errorTitleText', { + defaultMessage: 'Error updating agent logging level', + }), + }); + } + setIsLoading(false); + } + + send(); + }, [notifications, selectedLogLevel, agent.id]); + + return ( + + + + + + + + { + setSelectedLogLevel(event.target.value); + }} + options={LEVEL_VALUES.map((level) => ({ text: level, value: level }))} + /> + + {agentLogLevel !== selectedLogLevel && ( + + + {isLoading ? ( + + ) : ( + + )} + + + )} + + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx index 40346cde7f50ff..62adad14a028cc 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx @@ -17,7 +17,7 @@ import { SettingsPanel } from './settings_panel'; type ContentProps = PackageInfo & Pick; -const SideNavColumn = styled(LeftColumn)` +const LeftSideColumn = styled(LeftColumn)` /* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */ &&& { margin-top: 77px; @@ -30,15 +30,18 @@ const ContentFlexGroup = styled(EuiFlexGroup)` `; export function Content(props: ContentProps) { + const showRightColumn = props.panel !== 'policies'; return ( - - + + - - - + {showRightColumn && ( + + + + )} ); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx index 0e72693db9e2d0..aad8f9701923ef 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx @@ -237,8 +237,7 @@ export function Detail() { return (entries(PanelDisplayNames) .filter(([panelId]) => { return ( - panelId !== 'policies' || - (packageInfoData?.response.status === InstallStatus.installed && false) // Remove `false` when ready to implement policies tab + panelId !== 'policies' || packageInfoData?.response.status === InstallStatus.installed ); }) .map(([panelId, display]) => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/layout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/layout.tsx index c3295963847300..cbfab9ac9e5d24 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/layout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/layout.tsx @@ -6,31 +6,45 @@ import { EuiFlexItem } from '@elastic/eui'; import React, { FunctionComponent, ReactNode } from 'react'; +import { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item'; interface ColumnProps { children?: ReactNode; className?: string; + columnGrow?: FlexItemGrowSize; } -export const LeftColumn: FunctionComponent = ({ children, ...rest }) => { +export const LeftColumn: FunctionComponent = ({ + columnGrow = 2, + children, + ...rest +}) => { return ( - + {children} ); }; -export const CenterColumn: FunctionComponent = ({ children, ...rest }) => { +export const CenterColumn: FunctionComponent = ({ + columnGrow = 9, + children, + ...rest +}) => { return ( - + {children} ); }; -export const RightColumn: FunctionComponent = ({ children, ...rest }) => { +export const RightColumn: FunctionComponent = ({ + columnGrow = 3, + children, + ...rest +}) => { return ( - + {children} ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx index d97d891ac5e5d7..8609b08c9a7744 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx @@ -4,11 +4,82 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { memo, ReactNode, useCallback, useMemo } from 'react'; import { Redirect } from 'react-router-dom'; +import { + CriteriaWithPagination, + EuiBasicTable, + EuiLink, + EuiTableFieldDataColumnType, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedRelative } from '@kbn/i18n/react'; import { useGetPackageInstallStatus } from '../../hooks'; import { InstallStatus } from '../../../../types'; import { useLink } from '../../../../hooks'; +import { + AGENT_SAVED_OBJECT_TYPE, + PACKAGE_POLICY_SAVED_OBJECT_TYPE, +} from '../../../../../../../common/constants'; +import { useUrlPagination } from '../../../../hooks'; +import { + PackagePolicyAndAgentPolicy, + usePackagePoliciesWithAgentPolicy, +} from './use_package_policies_with_agent_policy'; +import { LinkAndRevision, LinkAndRevisionProps } from '../../../../components'; +import { Persona } from './persona'; + +const IntegrationDetailsLink = memo<{ + packagePolicy: PackagePolicyAndAgentPolicy['packagePolicy']; +}>(({ packagePolicy }) => { + const { getHref } = useLink(); + return ( + + {packagePolicy.name} + + ); +}); + +const AgentPolicyDetailLink = memo<{ + agentPolicyId: string; + revision: LinkAndRevisionProps['revision']; + children: ReactNode; +}>(({ agentPolicyId, revision, children }) => { + const { getHref } = useLink(); + return ( + + {children} + + ); +}); + +const PolicyAgentListLink = memo<{ agentPolicyId: string; children: ReactNode }>( + ({ agentPolicyId, children }) => { + const { getHref } = useLink(); + return ( + + {children} + + ); + } +); interface PackagePoliciesPanelProps { name: string; @@ -18,9 +89,118 @@ export const PackagePoliciesPanel = ({ name, version }: PackagePoliciesPanelProp const { getPath } = useLink(); const getPackageInstallStatus = useGetPackageInstallStatus(); const packageInstallStatus = getPackageInstallStatus(name); + const { pagination, pageSizeOptions, setPagination } = useUrlPagination(); + const { data } = usePackagePoliciesWithAgentPolicy({ + page: pagination.currentPage, + perPage: pagination.pageSize, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: ${name}`, + }); + + const handleTableOnChange = useCallback( + ({ page }: CriteriaWithPagination) => { + setPagination({ + currentPage: page.index + 1, + pageSize: page.size, + }); + }, + [setPagination] + ); + + const columns: Array> = useMemo( + () => [ + { + field: 'packagePolicy.name', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.name', { + defaultMessage: 'Integration', + }), + render(_, { packagePolicy }) { + return ; + }, + }, + { + field: 'packagePolicy.description', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.description', { + defaultMessage: 'Description', + }), + truncateText: true, + }, + { + field: 'packagePolicy.policy_id', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.agentPolicy', { + defaultMessage: 'Agent policy', + }), + truncateText: true, + render(id, { agentPolicy }) { + return ( + + {agentPolicy.name ?? id} + + ); + }, + }, + { + field: '', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.agentCount', { + defaultMessage: 'Agents', + }), + truncateText: true, + align: 'right', + width: '8ch', + render({ packagePolicy, agentPolicy }: PackagePolicyAndAgentPolicy) { + return ( + + {agentPolicy?.agents ?? 0} + + ); + }, + }, + { + field: 'packagePolicy.updated_by', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.updatedBy', { + defaultMessage: 'Last Updated By', + }), + truncateText: true, + render(updatedBy) { + return ; + }, + }, + { + field: 'packagePolicy.updated_at', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.updatedAt', { + defaultMessage: 'Last Updated', + }), + truncateText: true, + render(updatedAt: PackagePolicyAndAgentPolicy['packagePolicy']['updated_at']) { + return ( + + + + ); + }, + }, + ], + [] + ); + // if they arrive at this page and the package is not installed, send them to overview // this happens if they arrive with a direct url or they uninstall while on this tab - if (packageInstallStatus.status !== InstallStatus.installed) + if (packageInstallStatus.status !== InstallStatus.installed) { return ; - return null; + } + + return ( + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/persona.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/persona.tsx new file mode 100644 index 00000000000000..06b3c7a9a4093c --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/persona.tsx @@ -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 { EuiAvatar, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import React, { CSSProperties, memo, useCallback } from 'react'; +import { EuiAvatarProps } from '@elastic/eui/src/components/avatar/avatar'; + +const MIN_WIDTH: CSSProperties = { minWidth: 0 }; + +/** + * Shows a user's name along with an avatar. Name is truncated if its wider than the availble space + */ +export const Persona = memo( + ({ name, className, 'data-test-subj': dataTestSubj, title, ...otherAvatarProps }) => { + const getTestId = useCallback( + (suffix) => { + if (dataTestSubj) { + return `${dataTestSubj}-${suffix}`; + } + }, + [dataTestSubj] + ); + return ( + + + + + + + {name} + + + + ); + } +); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/use_package_policies_with_agent_policy.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/use_package_policies_with_agent_policy.ts new file mode 100644 index 00000000000000..d8a9d18e8a21f0 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/use_package_policies_with_agent_policy.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { PackagePolicy } from '../../../../../../../common/types/models'; +import { + GetAgentPoliciesResponse, + GetAgentPoliciesResponseItem, +} from '../../../../../../../common/types/rest_spec'; +import { useGetPackagePolicies } from '../../../../hooks/use_request'; +import { + SendConditionalRequestConfig, + useConditionalRequest, +} from '../../../../hooks/use_request/use_request'; +import { agentPolicyRouteService } from '../../../../../../../common/services'; +import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../../common/constants'; +import { GetPackagePoliciesResponse } from '../../../../../../../common/types/rest_spec'; + +export interface PackagePolicyEnriched extends PackagePolicy { + _agentPolicy: GetAgentPoliciesResponseItem | undefined; +} + +export interface PackagePolicyAndAgentPolicy { + packagePolicy: PackagePolicy; + agentPolicy: GetAgentPoliciesResponseItem; +} + +type GetPackagePoliciesWithAgentPolicy = Omit & { + items: PackagePolicyAndAgentPolicy[]; +}; + +/** + * Works similar to `useGetAgentPolicies()`, except that it will add an additional property to + * each package policy named `_agentPolicy` which may hold the Agent Policy associated with the + * given package policy. + * @param query + */ +export const usePackagePoliciesWithAgentPolicy = ( + query: Parameters[0] +): { + isLoading: boolean; + error: Error | null; + data?: GetPackagePoliciesWithAgentPolicy; +} => { + const { + data: packagePoliciesData, + error, + isLoading: isLoadingPackagePolicies, + } = useGetPackagePolicies(query); + + const agentPoliciesFilter = useMemo(() => { + if (!packagePoliciesData?.items.length) { + return ''; + } + + // Build a list of package_policies for which we need Agent Policies for. Since some package + // policies can exist within the same Agent Policy, we don't need to (in some cases) include + // the entire list of package_policy ids. + const includedAgentPolicies = new Set(); + + return `${AGENT_POLICY_SAVED_OBJECT_TYPE}.package_policies: (${packagePoliciesData.items + .filter((packagePolicy) => { + if (includedAgentPolicies.has(packagePolicy.policy_id)) { + return false; + } + includedAgentPolicies.add(packagePolicy.policy_id); + return true; + }) + .map((packagePolicy) => packagePolicy.id) + .join(' or ')}) `; + }, [packagePoliciesData]); + + const { + data: agentPoliciesData, + isLoading: isLoadingAgentPolicies, + } = useConditionalRequest({ + path: agentPolicyRouteService.getListPath(), + method: 'get', + query: { + perPage: 100, + kuery: agentPoliciesFilter, + }, + shouldSendRequest: !!packagePoliciesData?.items.length, + } as SendConditionalRequestConfig); + + const [enrichedData, setEnrichedData] = useState(); + + useEffect(() => { + if (isLoadingPackagePolicies || isLoadingAgentPolicies) { + return; + } + + if (!packagePoliciesData?.items) { + setEnrichedData(undefined); + return; + } + + const agentPoliciesById: Record = {}; + + if (agentPoliciesData?.items) { + for (const agentPolicy of agentPoliciesData.items) { + agentPoliciesById[agentPolicy.id] = agentPolicy; + } + } + + const updatedPackageData: PackagePolicyAndAgentPolicy[] = packagePoliciesData.items.map( + (packagePolicy) => { + return { + packagePolicy, + agentPolicy: agentPoliciesById[packagePolicy.policy_id], + }; + } + ); + + setEnrichedData({ + ...packagePoliciesData, + items: updatedPackageData, + }); + }, [isLoadingAgentPolicies, isLoadingPackagePolicies, packagePoliciesData, agentPoliciesData]); + + return { + data: enrichedData, + error, + isLoading: isLoadingPackagePolicies || isLoadingAgentPolicies, + }; +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts index 78cb355318d405..ded1447954aff7 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts @@ -67,6 +67,8 @@ export { PutAgentReassignResponse, PostBulkAgentReassignRequest, PostBulkAgentReassignResponse, + PostNewAgentActionResponse, + PostNewAgentActionRequest, // API schemas - Enrollment API Keys GetEnrollmentAPIKeysResponse, GetEnrollmentAPIKeysRequest, diff --git a/x-pack/plugins/fleet/scripts/dev_agent/script.ts b/x-pack/plugins/fleet/scripts/dev_agent/script.ts index 18ce12794776af..3babb03c2dac69 100644 --- a/x-pack/plugins/fleet/scripts/dev_agent/script.ts +++ b/x-pack/plugins/fleet/scripts/dev_agent/script.ts @@ -45,7 +45,7 @@ run( while (!closing) { await checkin(kibanaUrl, agent, log); - await new Promise((resolve, reject) => setTimeout(() => resolve(), CHECKIN_INTERVAL)); + await new Promise((resolve, reject) => setTimeout(() => resolve(), CHECKIN_INTERVAL)); } }, { diff --git a/x-pack/plugins/fleet/server/collectors/config_collectors.ts b/x-pack/plugins/fleet/server/collectors/config_collectors.ts index 8fb4924a2ccf0f..f26e4261d573e3 100644 --- a/x-pack/plugins/fleet/server/collectors/config_collectors.ts +++ b/x-pack/plugins/fleet/server/collectors/config_collectors.ts @@ -6,6 +6,6 @@ import { FleetConfigType } from '..'; -export const getIsFleetEnabled = (config: FleetConfigType) => { +export const getIsAgentsEnabled = (config: FleetConfigType) => { return config.agents.enabled; }; diff --git a/x-pack/plugins/fleet/server/collectors/register.ts b/x-pack/plugins/fleet/server/collectors/register.ts index e7d95a7e83773e..35517e6a7a7002 100644 --- a/x-pack/plugins/fleet/server/collectors/register.ts +++ b/x-pack/plugins/fleet/server/collectors/register.ts @@ -6,19 +6,19 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { CoreSetup } from 'kibana/server'; -import { getIsFleetEnabled } from './config_collectors'; +import { getIsAgentsEnabled } from './config_collectors'; import { AgentUsage, getAgentUsage } from './agent_collectors'; import { getInternalSavedObjectsClient } from './helpers'; import { PackageUsage, getPackageUsage } from './package_collectors'; import { FleetConfigType } from '..'; interface Usage { - fleet_enabled: boolean; + agents_enabled: boolean; agents: AgentUsage; packages: PackageUsage[]; } -export function registerIngestManagerUsageCollector( +export function registerFleetUsageCollector( core: CoreSetup, config: FleetConfigType, usageCollection: UsageCollectionSetup | undefined @@ -30,19 +30,19 @@ export function registerIngestManagerUsageCollector( } // create usage collector - const ingestManagerCollector = usageCollection.makeUsageCollector({ - type: 'ingest_manager', + const fleetCollector = usageCollection.makeUsageCollector({ + type: 'fleet', isReady: () => true, fetch: async () => { const soClient = await getInternalSavedObjectsClient(core); return { - fleet_enabled: getIsFleetEnabled(config), + agents_enabled: getIsAgentsEnabled(config), agents: await getAgentUsage(soClient), packages: await getPackageUsage(soClient), }; }, schema: { - fleet_enabled: { type: 'boolean' }, + agents_enabled: { type: 'boolean' }, agents: { total: { type: 'long' }, online: { type: 'long' }, @@ -61,5 +61,5 @@ export function registerIngestManagerUsageCollector( }); // register usage collector - usageCollection.registerCollector(ingestManagerCollector); + usageCollection.registerCollector(fleetCollector); } diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 90fb34efd4817e..716939c28bf1e2 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -71,7 +71,7 @@ import { } from './services/agents'; import { CloudSetup } from '../../cloud/server'; import { agentCheckinState } from './services/agents/checkin/state'; -import { registerIngestManagerUsageCollector } from './collectors/register'; +import { registerFleetUsageCollector } from './collectors/register'; import { getInstallation } from './services/epm/packages'; export interface FleetSetupDeps { @@ -216,7 +216,7 @@ export class FleetPlugin const config = await this.config$.pipe(first()).toPromise(); // Register usage collection - registerIngestManagerUsageCollector(core, config, deps.usageCollection); + registerFleetUsageCollector(core, config, deps.usageCollection); // Always register app routes for permissions checking registerAppRoutes(router); diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts index 28ddf9704bd925..9c87eaa1859cd2 100644 --- a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts @@ -85,9 +85,9 @@ describe('test agent checkin new action services', () => { it('should not fetch actions concurrently', async () => { const observable = createNewActionsSharedObservable(); - const resolves: Array<() => void> = []; + const resolves: Array<(value?: any) => void> = []; getMockedNewActionSince().mockImplementation(() => { - return new Promise((resolve) => { + return new Promise((resolve) => { resolves.push(resolve); }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/archive/extract.ts b/x-pack/plugins/fleet/server/services/epm/archive/extract.ts index 6ac81a25dfc21e..1e8f7ce416df19 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/extract.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/extract.ts @@ -69,7 +69,7 @@ export function getBufferExtractor( function yauzlFromBuffer(buffer: Buffer, opts: yauzl.Options): Promise { return new Promise((resolve, reject) => yauzl.fromBuffer(buffer, opts, (err?: Error, handle?: yauzl.ZipFile) => - err ? reject(err) : resolve(handle) + err ? reject(err) : resolve(handle!) ) ); } @@ -80,7 +80,7 @@ function getZipReadStream( ): Promise { return new Promise((resolve, reject) => zipfile.openReadStream(entry, (err?: Error, readStream?: NodeJS.ReadableStream) => - err ? reject(err) : resolve(readStream) + err ? reject(err) : resolve(readStream!) ) ); } diff --git a/x-pack/plugins/global_search/common/types.ts b/x-pack/plugins/global_search/common/types.ts index a08ecaf41b2137..7cc1d7ada44229 100644 --- a/x-pack/plugins/global_search/common/types.ts +++ b/x-pack/plugins/global_search/common/types.ts @@ -87,3 +87,28 @@ export interface GlobalSearchBatchedResults { */ results: GlobalSearchResult[]; } + +/** + * Search parameters for the {@link GlobalSearchPluginStart.find | `find` API} + * + * @public + */ +export interface GlobalSearchFindParams { + /** + * The term to search for. Can be undefined if searching by filters. + */ + term?: string; + /** + * The types of results to search for. + */ + types?: string[]; + /** + * The tag ids to filter search by. + */ + tags?: string[]; +} + +/** + * @public + */ +export type GlobalSearchProviderFindParams = GlobalSearchFindParams; diff --git a/x-pack/plugins/global_search/public/index.ts b/x-pack/plugins/global_search/public/index.ts index 18483cea725402..0e1cbaedae7821 100644 --- a/x-pack/plugins/global_search/public/index.ts +++ b/x-pack/plugins/global_search/public/index.ts @@ -25,6 +25,8 @@ export { GlobalSearchProviderResult, GlobalSearchProviderResultUrl, GlobalSearchResult, + GlobalSearchFindParams, + GlobalSearchProviderFindParams, } from '../common/types'; export { GlobalSearchPluginSetup, diff --git a/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts b/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts index f62acd08633ff1..4794c355a161bc 100644 --- a/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts +++ b/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts @@ -33,11 +33,18 @@ describe('fetchServerResults', () => { it('perform a POST request to the endpoint with valid options', () => { http.post.mockResolvedValue({ results: [] }); - fetchServerResults(http, 'some term', { preference: 'pref' }); + fetchServerResults( + http, + { term: 'some term', types: ['dashboard', 'map'] }, + { preference: 'pref' } + ); expect(http.post).toHaveBeenCalledTimes(1); expect(http.post).toHaveBeenCalledWith('/internal/global_search/find', { - body: JSON.stringify({ term: 'some term', options: { preference: 'pref' } }), + body: JSON.stringify({ + params: { term: 'some term', types: ['dashboard', 'map'] }, + options: { preference: 'pref' }, + }), }); }); @@ -47,7 +54,11 @@ describe('fetchServerResults', () => { http.post.mockResolvedValue({ results: [resultA, resultB] }); - const results = await fetchServerResults(http, 'some term', { preference: 'pref' }).toPromise(); + const results = await fetchServerResults( + http, + { term: 'some term' }, + { preference: 'pref' } + ).toPromise(); expect(http.post).toHaveBeenCalledTimes(1); expect(results).toHaveLength(2); @@ -65,7 +76,7 @@ describe('fetchServerResults', () => { getTestScheduler().run(({ expectObservable, hot }) => { http.post.mockReturnValue(hot('---(a|)', { a: { results: [] } }) as any); - const results = fetchServerResults(http, 'term', {}); + const results = fetchServerResults(http, { term: 'term' }, {}); expectObservable(results).toBe('---(a|)', { a: [], @@ -77,7 +88,7 @@ describe('fetchServerResults', () => { getTestScheduler().run(({ expectObservable, hot }) => { http.post.mockReturnValue(hot('---(a|)', { a: { results: [] } }) as any); const aborted$ = hot('-(a|)', { a: undefined }); - const results = fetchServerResults(http, 'term', { aborted$ }); + const results = fetchServerResults(http, { term: 'term' }, { aborted$ }); expectObservable(results).toBe('-|', { a: [], diff --git a/x-pack/plugins/global_search/public/services/fetch_server_results.ts b/x-pack/plugins/global_search/public/services/fetch_server_results.ts index 3c06dfab9f50e3..7508c8db571656 100644 --- a/x-pack/plugins/global_search/public/services/fetch_server_results.ts +++ b/x-pack/plugins/global_search/public/services/fetch_server_results.ts @@ -7,7 +7,7 @@ import { Observable, from, EMPTY } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { HttpStart } from 'src/core/public'; -import { GlobalSearchResult } from '../../common/types'; +import { GlobalSearchResult, GlobalSearchProviderFindParams } from '../../common/types'; import { GlobalSearchFindOptions } from './types'; interface ServerFetchResponse { @@ -24,7 +24,7 @@ interface ServerFetchResponse { */ export const fetchServerResults = ( http: HttpStart, - term: string, + params: GlobalSearchProviderFindParams, { preference, aborted$ }: GlobalSearchFindOptions ): Observable => { let controller: AbortController | undefined; @@ -36,7 +36,7 @@ export const fetchServerResults = ( } return from( http.post('/internal/global_search/find', { - body: JSON.stringify({ term, options: { preference } }), + body: JSON.stringify({ params, options: { preference } }), signal: controller?.signal, }) ).pipe( diff --git a/x-pack/plugins/global_search/public/services/search_service.test.ts b/x-pack/plugins/global_search/public/services/search_service.test.ts index 350547a928fe4b..419ad847d6c29d 100644 --- a/x-pack/plugins/global_search/public/services/search_service.test.ts +++ b/x-pack/plugins/global_search/public/services/search_service.test.ts @@ -116,11 +116,14 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start(startDeps()); - find('foobar', { preference: 'pref' }); + find( + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, + { preference: 'pref' } + ); expect(provider.find).toHaveBeenCalledTimes(1); expect(provider.find).toHaveBeenCalledWith( - 'foobar', + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, expect.objectContaining({ preference: 'pref' }) ); }); @@ -129,12 +132,15 @@ describe('SearchService', () => { service.setup({ config: createConfig() }); const { find } = service.start(startDeps()); - find('foobar', { preference: 'pref' }); + find( + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, + { preference: 'pref' } + ); expect(fetchServerResultsMock).toHaveBeenCalledTimes(1); expect(fetchServerResultsMock).toHaveBeenCalledWith( httpStart, - 'foobar', + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, expect.objectContaining({ preference: 'pref', aborted$: expect.any(Object) }) ); }); @@ -148,25 +154,25 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start(startDeps()); - find('foobar', { preference: 'pref' }); + find({ term: 'foobar' }, { preference: 'pref' }); expect(getDefaultPreferenceMock).not.toHaveBeenCalled(); expect(provider.find).toHaveBeenNthCalledWith( 1, - 'foobar', + { term: 'foobar' }, expect.objectContaining({ preference: 'pref', }) ); - find('foobar', {}); + find({ term: 'foobar' }, {}); expect(getDefaultPreferenceMock).toHaveBeenCalledTimes(1); expect(provider.find).toHaveBeenNthCalledWith( 2, - 'foobar', + { term: 'foobar' }, expect.objectContaining({ preference: 'default_pref', }) @@ -186,7 +192,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a-b-|', { a: expectedBatch('1'), @@ -207,7 +213,7 @@ describe('SearchService', () => { fetchServerResultsMock.mockReturnValue(serverResults); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a-b-|', { a: expectedBatch('1'), @@ -242,7 +248,7 @@ describe('SearchService', () => { ); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('ab-cd-|', { a: expectedBatch('A1', 'A2'), @@ -276,7 +282,7 @@ describe('SearchService', () => { ); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a-b--(c|)', { a: expectedBatch('P1'), @@ -301,7 +307,7 @@ describe('SearchService', () => { const aborted$ = hot('----a--|', { a: undefined }); const { find } = service.start(startDeps()); - const results = find('foo', { aborted$ }); + const results = find({ term: 'foobar' }, { aborted$ }); expectObservable(results).toBe('--a-|', { a: expectedBatch('1'), @@ -323,7 +329,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a 24ms b 74ms |', { a: expectedBatch('1'), @@ -359,7 +365,7 @@ describe('SearchService', () => { ); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('ab-(c|)', { a: expectedBatch('A1', 'A2'), @@ -392,7 +398,7 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start(startDeps()); - const batch = await find('foo', {}).pipe(take(1)).toPromise(); + const batch = await find({ term: 'foobar' }, {}).pipe(take(1)).toPromise(); expect(batch.results).toHaveLength(2); expect(batch.results[0]).toEqual({ @@ -420,7 +426,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe( '#', diff --git a/x-pack/plugins/global_search/public/services/search_service.ts b/x-pack/plugins/global_search/public/services/search_service.ts index 62b347d9258688..64bd2fd6c930f7 100644 --- a/x-pack/plugins/global_search/public/services/search_service.ts +++ b/x-pack/plugins/global_search/public/services/search_service.ts @@ -9,7 +9,11 @@ import { map, takeUntil } from 'rxjs/operators'; import { duration } from 'moment'; import { i18n } from '@kbn/i18n'; import { HttpStart } from 'src/core/public'; -import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; +import { + GlobalSearchFindParams, + GlobalSearchProviderResult, + GlobalSearchBatchedResults, +} from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; import { defaultMaxProviderResults } from '../../common/constants'; @@ -52,7 +56,7 @@ export interface SearchServiceStart { * * @example * ```ts - * startDeps.globalSearch.find('some term').subscribe({ + * startDeps.globalSearch.find({term: 'some term'}).subscribe({ * next: ({ results }) => { * addNewResultsToList(results); * }, @@ -67,7 +71,10 @@ export interface SearchServiceStart { * Emissions from the resulting observable will only contains **new** results. It is the consumer's * responsibility to aggregate the emission and sort the results if required. */ - find(term: string, options: GlobalSearchFindOptions): Observable; + find( + params: GlobalSearchFindParams, + options: GlobalSearchFindOptions + ): Observable; } interface SetupDeps { @@ -110,11 +117,11 @@ export class SearchService { this.licenseChecker = licenseChecker; return { - find: (term, options) => this.performFind(term, options), + find: (params, options) => this.performFind(params, options), }; } - private performFind(term: string, options: GlobalSearchFindOptions) { + private performFind(params: GlobalSearchFindParams, options: GlobalSearchFindOptions) { const licenseState = this.licenseChecker!.getState(); if (!licenseState.valid) { return throwError( @@ -142,13 +149,13 @@ export class SearchService { const processResult = (result: GlobalSearchProviderResult) => processProviderResult(result, this.http!.basePath); - const serverResults$ = fetchServerResults(this.http!, term, { + const serverResults$ = fetchServerResults(this.http!, params, { preference, aborted$, }); const providersResults$ = [...this.providers.values()].map((provider) => - provider.find(term, providerOptions).pipe( + provider.find(params, providerOptions).pipe( takeInArray(this.maxProviderResults), takeUntil(aborted$), map((results) => results.map((r) => processResult(r))) diff --git a/x-pack/plugins/global_search/public/types.ts b/x-pack/plugins/global_search/public/types.ts index 42ef234504d12b..2707a2fded222a 100644 --- a/x-pack/plugins/global_search/public/types.ts +++ b/x-pack/plugins/global_search/public/types.ts @@ -5,7 +5,11 @@ */ import { Observable } from 'rxjs'; -import { GlobalSearchProviderFindOptions, GlobalSearchProviderResult } from '../common/types'; +import { + GlobalSearchProviderFindOptions, + GlobalSearchProviderResult, + GlobalSearchProviderFindParams, +} from '../common/types'; import { SearchServiceSetup, SearchServiceStart } from './services'; export type GlobalSearchPluginSetup = Pick; @@ -29,7 +33,7 @@ export interface GlobalSearchResultProvider { * // returning all results in a single batch * setupDeps.globalSearch.registerResultProvider({ * id: 'my_provider', - * find: (term, { aborted$, preference, maxResults }, context) => { + * find: ({ term, filters }, { aborted$, preference, maxResults }, context) => { * const resultPromise = myService.search(term, { preference, maxResults }, context.core.savedObjects.client); * return from(resultPromise).pipe(takeUntil(aborted$)); * }, @@ -37,7 +41,7 @@ export interface GlobalSearchResultProvider { * ``` */ find( - term: string, + search: GlobalSearchProviderFindParams, options: GlobalSearchProviderFindOptions ): Observable; } diff --git a/x-pack/plugins/global_search/server/routes/find.ts b/x-pack/plugins/global_search/server/routes/find.ts index a9063abda0e3eb..0b82a035348ed9 100644 --- a/x-pack/plugins/global_search/server/routes/find.ts +++ b/x-pack/plugins/global_search/server/routes/find.ts @@ -15,7 +15,11 @@ export const registerInternalFindRoute = (router: IRouter) => { path: '/internal/global_search/find', validate: { body: schema.object({ - term: schema.string(), + params: schema.object({ + term: schema.maybe(schema.string()), + types: schema.maybe(schema.arrayOf(schema.string())), + tags: schema.maybe(schema.arrayOf(schema.string())), + }), options: schema.maybe( schema.object({ preference: schema.maybe(schema.string()), @@ -25,10 +29,10 @@ export const registerInternalFindRoute = (router: IRouter) => { }, }, async (ctx, req, res) => { - const { term, options } = req.body; + const { params, options } = req.body; try { const allResults = await ctx - .globalSearch!.find(term, { ...options, aborted$: req.events.aborted$ }) + .globalSearch!.find(params, { ...options, aborted$: req.events.aborted$ }) .pipe( map((batch) => batch.results), reduce((acc, results) => [...acc, ...results]) diff --git a/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts index ed28786782c354..c37bcdbf847438 100644 --- a/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts +++ b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts @@ -62,7 +62,9 @@ describe('POST /internal/global_search/find', () => { await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, options: { preference: 'custom-pref', }, @@ -70,10 +72,13 @@ describe('POST /internal/global_search/find', () => { .expect(200); expect(globalSearchHandlerContext.find).toHaveBeenCalledTimes(1); - expect(globalSearchHandlerContext.find).toHaveBeenCalledWith('search', { - preference: 'custom-pref', - aborted$: expect.any(Object), - }); + expect(globalSearchHandlerContext.find).toHaveBeenCalledWith( + { term: 'search' }, + { + preference: 'custom-pref', + aborted$: expect.any(Object), + } + ); }); it('returns all the results returned from the service', async () => { @@ -84,7 +89,9 @@ describe('POST /internal/global_search/find', () => { const response = await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, }) .expect(200); @@ -101,7 +108,9 @@ describe('POST /internal/global_search/find', () => { const response = await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, }) .expect(403); @@ -119,7 +128,9 @@ describe('POST /internal/global_search/find', () => { const response = await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, }) .expect(500); diff --git a/x-pack/plugins/global_search/server/services/search_service.test.ts b/x-pack/plugins/global_search/server/services/search_service.test.ts index 2460100a46dbbe..c8d656a524e94e 100644 --- a/x-pack/plugins/global_search/server/services/search_service.test.ts +++ b/x-pack/plugins/global_search/server/services/search_service.test.ts @@ -97,11 +97,15 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start({ core: coreStart, licenseChecker }); - find('foobar', { preference: 'pref' }, request); + find( + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, + { preference: 'pref' }, + request + ); expect(provider.find).toHaveBeenCalledTimes(1); expect(provider.find).toHaveBeenCalledWith( - 'foobar', + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, expect.objectContaining({ preference: 'pref' }), expect.objectContaining({ core: expect.any(Object) }) ); @@ -121,7 +125,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('a-b-|', { a: expectedBatch('1'), @@ -157,7 +161,7 @@ describe('SearchService', () => { ); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('ab-cd-|', { a: expectedBatch('A1', 'A2'), @@ -184,7 +188,7 @@ describe('SearchService', () => { const aborted$ = hot('----a--|', { a: undefined }); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', { aborted$ }, request); + const results = find({ term: 'foobar' }, { aborted$ }, request); expectObservable(results).toBe('--a-|', { a: expectedBatch('1'), @@ -207,7 +211,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('a 24ms b 74ms |', { a: expectedBatch('1'), @@ -244,7 +248,7 @@ describe('SearchService', () => { ); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('ab-(c|)', { a: expectedBatch('A1', 'A2'), @@ -278,7 +282,7 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start({ core: coreStart, licenseChecker }); - const batch = await find('foo', {}, request).pipe(take(1)).toPromise(); + const batch = await find({ term: 'foobar' }, {}, request).pipe(take(1)).toPromise(); expect(batch.results).toHaveLength(2); expect(batch.results[0]).toEqual({ @@ -307,7 +311,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe( '#', diff --git a/x-pack/plugins/global_search/server/services/search_service.ts b/x-pack/plugins/global_search/server/services/search_service.ts index 1897a24196cf10..9ea62abac704ca 100644 --- a/x-pack/plugins/global_search/server/services/search_service.ts +++ b/x-pack/plugins/global_search/server/services/search_service.ts @@ -8,12 +8,15 @@ import { Observable, timer, merge, throwError } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, CoreStart, IBasePath } from 'src/core/server'; -import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; +import { + GlobalSearchProviderResult, + GlobalSearchBatchedResults, + GlobalSearchFindParams, +} from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; import { defaultMaxProviderResults } from '../../common/constants'; import { ILicenseChecker } from '../../common/license_checker'; - import { processProviderResult } from '../../common/process_result'; import { GlobalSearchConfigType } from '../config'; import { getContextFactory, GlobalSearchContextFactory } from './context'; @@ -46,7 +49,7 @@ export interface SearchServiceStart { * * @example * ```ts - * startDeps.globalSearch.find('some term').subscribe({ + * startDeps.globalSearch.find({ term: 'some term' }).subscribe({ * next: ({ results }) => { * addNewResultsToList(results); * }, @@ -64,7 +67,7 @@ export interface SearchServiceStart { * from the server-side `find` API. */ find( - term: string, + params: GlobalSearchFindParams, options: GlobalSearchFindOptions, request: KibanaRequest ): Observable; @@ -115,11 +118,15 @@ export class SearchService { this.licenseChecker = licenseChecker; this.contextFactory = getContextFactory(core); return { - find: (term, options, request) => this.performFind(term, options, request), + find: (params, options, request) => this.performFind(params, options, request), }; } - private performFind(term: string, options: GlobalSearchFindOptions, request: KibanaRequest) { + private performFind( + params: GlobalSearchFindParams, + options: GlobalSearchFindOptions, + request: KibanaRequest + ) { const licenseState = this.licenseChecker!.getState(); if (!licenseState.valid) { return throwError( @@ -137,7 +144,7 @@ export class SearchService { const timeout$ = timer(this.config!.search_timeout.asMilliseconds()).pipe(map(mapToUndefined)); const aborted$ = options.aborted$ ? merge(options.aborted$, timeout$) : timeout$; - const providerOptions = { + const findOptions = { ...options, preference: options.preference ?? 'default', maxResults: this.maxProviderResults, @@ -148,7 +155,7 @@ export class SearchService { processProviderResult(result, basePath); const providersResults$ = [...this.providers.values()].map((provider) => - provider.find(term, providerOptions, context).pipe( + provider.find(params, findOptions, context).pipe( takeInArray(this.maxProviderResults), takeUntil(aborted$), map((results) => results.map((r) => processResult(r))) diff --git a/x-pack/plugins/global_search/server/types.ts b/x-pack/plugins/global_search/server/types.ts index 07d21f54d7bf59..0878a965ea8c31 100644 --- a/x-pack/plugins/global_search/server/types.ts +++ b/x-pack/plugins/global_search/server/types.ts @@ -16,6 +16,8 @@ import { GlobalSearchBatchedResults, GlobalSearchProviderFindOptions, GlobalSearchProviderResult, + GlobalSearchProviderFindParams, + GlobalSearchFindParams, } from '../common/types'; import { SearchServiceSetup, SearchServiceStart } from './services'; @@ -31,7 +33,10 @@ export interface RouteHandlerGlobalSearchContext { /** * See {@link SearchServiceStart.find | the find API} */ - find(term: string, options: GlobalSearchFindOptions): Observable; + find( + params: GlobalSearchFindParams, + options: GlobalSearchFindOptions + ): Observable; } /** @@ -97,7 +102,7 @@ export interface GlobalSearchResultProvider { * // returning all results in a single batch * setupDeps.globalSearch.registerResultProvider({ * id: 'my_provider', - * find: (term, { aborted$, preference, maxResults }, context) => { + * find: ({term, filters }, { aborted$, preference, maxResults }, context) => { * const resultPromise = myService.search(term, { preference, maxResults }, context.core.savedObjects.client); * return from(resultPromise).pipe(takeUntil(aborted$)); * }, @@ -105,7 +110,7 @@ export interface GlobalSearchResultProvider { * ``` */ find( - term: string, + search: GlobalSearchProviderFindParams, options: GlobalSearchProviderFindOptions, context: GlobalSearchProviderContext ): Observable; diff --git a/x-pack/plugins/global_search_bar/kibana.json b/x-pack/plugins/global_search_bar/kibana.json index bf0ae83a0d863f..85e091fe1abadd 100644 --- a/x-pack/plugins/global_search_bar/kibana.json +++ b/x-pack/plugins/global_search_bar/kibana.json @@ -5,6 +5,6 @@ "server": false, "ui": true, "requiredPlugins": ["globalSearch"], - "optionalPlugins": ["usageCollection"], + "optionalPlugins": ["usageCollection", "savedObjectsTagging"], "configPath": ["xpack", "global_search_bar"] } diff --git a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap index bf7eacd2b52a11..de45d8ea5dfaf1 100644 --- a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap +++ b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap @@ -36,7 +36,7 @@ exports[`SearchBar supports keyboard shortcuts 1`] = ` aria-label="Filter options" autocomplete="off" class="euiFieldSearch euiFieldSearch--fullWidth euiFieldSearch--compressed euiSelectableSearch euiSelectableTemplateSitewide__search" - data-test-subj="header-search" + data-test-subj="nav-search-input" placeholder="Search Elastic" type="search" value="" diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx index a3e2d66eabe5b9..5ba00c293d2139 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx @@ -54,7 +54,7 @@ describe('SearchBar', () => { }); const triggerFocus = () => { - component.find('input[data-test-subj="header-search"]').simulate('focus'); + component.find('input[data-test-subj="nav-search-input"]').simulate('focus'); }; const update = () => { @@ -100,7 +100,7 @@ describe('SearchBar', () => { update(); expect(searchService.find).toHaveBeenCalledTimes(1); - expect(searchService.find).toHaveBeenCalledWith('', {}); + expect(searchService.find).toHaveBeenCalledWith({}, {}); expect(getDisplayedOptionsTitle()).toMatchSnapshot(); await simulateTypeChar('d'); @@ -108,7 +108,7 @@ describe('SearchBar', () => { expect(getDisplayedOptionsTitle()).toMatchSnapshot(); expect(searchService.find).toHaveBeenCalledTimes(2); - expect(searchService.find).toHaveBeenCalledWith('d', {}); + expect(searchService.find).toHaveBeenCalledWith({ term: 'd' }, {}); }); it('supports keyboard shortcuts', () => { diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx index adc55329962e91..3746e636066a93 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -5,7 +5,7 @@ */ import { - EuiBadge, + EuiCode, EuiFlexGroup, EuiFlexItem, EuiHeaderSectionItemButton, @@ -25,11 +25,18 @@ import useDebounce from 'react-use/lib/useDebounce'; import useEvent from 'react-use/lib/useEvent'; import useMountedState from 'react-use/lib/useMountedState'; import { Subscription } from 'rxjs'; -import { GlobalSearchPluginStart, GlobalSearchResult } from '../../../global_search/public'; +import { + GlobalSearchPluginStart, + GlobalSearchResult, + GlobalSearchFindParams, +} from '../../../global_search/public'; +import { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public'; +import { parseSearchParams } from '../search_syntax'; interface Props { globalSearch: GlobalSearchPluginStart['find']; navigateToUrl: ApplicationStart['navigateToUrl']; + taggingApi?: SavedObjectTaggingPluginStart; trackUiMetric: (metricType: UiStatsMetricType, eventName: string | string[]) => void; basePathUrl: string; darkMode: boolean; @@ -64,17 +71,17 @@ const sortByTitle = (a: GlobalSearchResult, b: GlobalSearchResult): number => { const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewideOption => { const { id, title, url, icon, type, meta } = result; + // only displaying icons for applications + const useIcon = type === 'application'; const option: EuiSelectableTemplateSitewideOption = { key: id, label: title, url, type, + icon: { type: useIcon && icon ? icon : 'empty' }, + 'data-test-subj': `nav-search-option`, }; - if (icon) { - option.icon = { type: icon }; - } - if (type === 'application') { option.meta = [{ text: meta?.categoryLabel as string }]; } else { @@ -86,6 +93,7 @@ const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewi export function SearchBar({ globalSearch, + taggingApi, navigateToUrl, trackUiMetric, basePathUrl, @@ -119,8 +127,24 @@ export function SearchBar({ } let arr: GlobalSearchResult[] = []; - if (searchValue.length !== 0) trackUiMetric(METRIC_TYPE.COUNT, 'search_request'); - searchSubscription.current = globalSearch(searchValue, {}).subscribe({ + if (searchValue.length !== 0) { + trackUiMetric(METRIC_TYPE.COUNT, 'search_request'); + } + + const rawParams = parseSearchParams(searchValue); + const tagIds = + taggingApi && rawParams.filters.tags + ? rawParams.filters.tags.map( + (tagName) => taggingApi.ui.getTagIdFromName(tagName) ?? '__unknown__' + ) + : undefined; + const searchParams: GlobalSearchFindParams = { + term: rawParams.term, + types: rawParams.filters.types, + tags: tagIds, + }; + + searchSubscription.current = globalSearch(searchParams, {}).subscribe({ next: ({ results }) => { if (searchValue.length > 0) { arr = [...results, ...arr].sort(sortByScore); @@ -197,7 +221,7 @@ export function SearchBar({ }; const emptyMessage = ( - + } searchProps={{ + onSearch: () => undefined, onKeyUpCapture: (e: React.KeyboardEvent) => setSearchValue(e.currentTarget.value), - 'data-test-subj': 'header-search', + 'data-test-subj': 'nav-search-input', inputRef: setSearchRef, compressed: true, placeholder: i18n.translate('xpack.globalSearchBar.searchBar.placeholder', { @@ -256,6 +281,8 @@ export function SearchBar({ }, }} popoverProps={{ + 'data-test-subj': 'nav-search-popover', + panelClassName: 'navSearch__panel', repositionOnScroll: true, buttonRef: setButtonRef, }} @@ -265,42 +292,58 @@ export function SearchBar({ - - - - ), - commandDescription: ( - - - {isMac ? ( - - ) : ( - - )} - - - ), - }} - /> + +

+ +   + type:  + +   + tag: +

+
+ +

+ + ), + commandDescription: ( + + {isMac ? ( + + ) : ( + + )} + + ), + }} + /> +

+
} diff --git a/x-pack/plugins/global_search_bar/public/plugin.tsx b/x-pack/plugins/global_search_bar/public/plugin.tsx index 14ac0935467d7d..81951843ee8b5a 100644 --- a/x-pack/plugins/global_search_bar/public/plugin.tsx +++ b/x-pack/plugins/global_search_bar/public/plugin.tsx @@ -4,19 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; +import ReactDOM from 'react-dom'; import { UiStatsMetricType } from '@kbn/analytics'; import { I18nProvider } from '@kbn/i18n/react'; import { ApplicationStart } from 'kibana/public'; -import React from 'react'; -import ReactDOM from 'react-dom'; import { CoreStart, Plugin } from 'src/core/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { GlobalSearchPluginStart } from '../../global_search/public'; -import { SearchBar } from '../public/components/search_bar'; +import { SavedObjectTaggingPluginStart } from '../../saved_objects_tagging/public'; +import { SearchBar } from './components/search_bar'; export interface GlobalSearchBarPluginStartDeps { globalSearch: GlobalSearchPluginStart; - usageCollection: UsageCollectionSetup; + savedObjectsTagging?: SavedObjectTaggingPluginStart; + usageCollection?: UsageCollectionSetup; } export class GlobalSearchBarPlugin implements Plugin<{}, {}> { @@ -24,49 +26,61 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> { return {}; } - public start(core: CoreStart, { globalSearch, usageCollection }: GlobalSearchBarPluginStartDeps) { - let trackUiMetric = (metricType: UiStatsMetricType, eventName: string | string[]) => {}; - - if (usageCollection) { - trackUiMetric = usageCollection.reportUiStats.bind(usageCollection, 'global_search_bar'); - } + public start( + core: CoreStart, + { globalSearch, savedObjectsTagging, usageCollection }: GlobalSearchBarPluginStartDeps + ) { + const trackUiMetric = usageCollection + ? usageCollection.reportUiStats.bind(usageCollection, 'global_search_bar') + : (metricType: UiStatsMetricType, eventName: string | string[]) => {}; core.chrome.navControls.registerCenter({ order: 1000, - mount: (target) => - this.mount( - target, + mount: (container) => + this.mount({ + container, globalSearch, - core.application.navigateToUrl, - core.http.basePath.prepend('/plugins/globalSearchBar/assets/'), - core.uiSettings.get('theme:darkMode'), - trackUiMetric - ), + savedObjectsTagging, + navigateToUrl: core.application.navigateToUrl, + basePathUrl: core.http.basePath.prepend('/plugins/globalSearchBar/assets/'), + darkMode: core.uiSettings.get('theme:darkMode'), + trackUiMetric, + }), }); return {}; } - private mount( - targetDomElement: HTMLElement, - globalSearch: GlobalSearchPluginStart, - navigateToUrl: ApplicationStart['navigateToUrl'], - basePathUrl: string, - darkMode: boolean, - trackUiMetric: (metricType: UiStatsMetricType, eventName: string | string[]) => void - ) { + private mount({ + container, + globalSearch, + savedObjectsTagging, + navigateToUrl, + basePathUrl, + darkMode, + trackUiMetric, + }: { + container: HTMLElement; + globalSearch: GlobalSearchPluginStart; + savedObjectsTagging?: SavedObjectTaggingPluginStart; + navigateToUrl: ApplicationStart['navigateToUrl']; + basePathUrl: string; + darkMode: boolean; + trackUiMetric: (metricType: UiStatsMetricType, eventName: string | string[]) => void; + }) { ReactDOM.render( , - targetDomElement + container ); - return () => ReactDOM.unmountComponentAtNode(targetDomElement); + return () => ReactDOM.unmountComponentAtNode(container); } } diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/index.ts b/x-pack/plugins/global_search_bar/public/search_syntax/index.ts new file mode 100644 index 00000000000000..01c52e468af3a4 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { parseSearchParams } from './parse_search_params'; +export { ParsedSearchParams, FilterValues, FilterValueType } from './types'; diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.test.ts b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.test.ts new file mode 100644 index 00000000000000..3b00389b8605d0 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parseSearchParams } from './parse_search_params'; + +describe('parseSearchParams', () => { + it('returns the correct term', () => { + const searchParams = parseSearchParams('tag:(my-tag OR other-tag) hello'); + expect(searchParams.term).toEqual('hello'); + }); + + it('returns the raw query as `term` in case of parsing error', () => { + const searchParams = parseSearchParams('tag:((()^invalid'); + expect(searchParams).toEqual({ + term: 'tag:((()^invalid', + filters: { + unknowns: {}, + }, + }); + }); + + it('returns `undefined` term if query only contains field clauses', () => { + const searchParams = parseSearchParams('tag:(my-tag OR other-tag)'); + expect(searchParams.term).toBeUndefined(); + }); + + it('returns correct filters when no field clause is defined', () => { + const searchParams = parseSearchParams('hello'); + expect(searchParams.filters).toEqual({ + tags: undefined, + types: undefined, + unknowns: {}, + }); + }); + + it('returns correct filters when field clauses are present', () => { + const searchParams = parseSearchParams('tag:foo type:bar hello tag:dolly'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['foo', 'dolly'], + types: ['bar'], + unknowns: {}, + }, + }); + }); + + it('handles unknowns field clauses', () => { + const searchParams = parseSearchParams('tag:foo unknown:bar hello'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['foo'], + unknowns: { + unknown: ['bar'], + }, + }, + }); + }); + + it('handles aliases field clauses', () => { + const searchParams = parseSearchParams('tag:foo tags:bar type:dash types:board hello'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['foo', 'bar'], + types: ['dash', 'board'], + unknowns: {}, + }, + }); + }); + + it('converts boolean and number values to string for known filters', () => { + const searchParams = parseSearchParams('tag:42 tags:true type:69 types:false hello'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['42', 'true'], + types: ['69', 'false'], + unknowns: {}, + }, + }); + }); +}); diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.ts b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.ts new file mode 100644 index 00000000000000..83117ddfb507d1 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Query } from '@elastic/eui'; +import { getSearchTerm, getFieldValueMap, applyAliases } from './query_utils'; +import { FilterValues, ParsedSearchParams } from './types'; + +const knownFilters = ['tag', 'type']; + +const aliasMap = { + tag: ['tags'], + type: ['types'], +}; + +export const parseSearchParams = (term: string): ParsedSearchParams => { + let query: Query; + + try { + query = Query.parse(term); + } catch (e) { + // if the query fails to parse, we just perform the search against the raw search term. + return { + term, + filters: { + unknowns: {}, + }, + }; + } + + const searchTerm = getSearchTerm(query); + const filterValues = applyAliases(getFieldValueMap(query), aliasMap); + + const unknownFilters = [...filterValues.entries()] + .filter(([key]) => !knownFilters.includes(key)) + .reduce((unknowns, [key, value]) => { + return { + ...unknowns, + [key]: value, + }; + }, {} as Record); + + const tags = filterValues.get('tag'); + const types = filterValues.get('type'); + + return { + term: searchTerm, + filters: { + tags: tags ? valuesToString(tags) : undefined, + types: types ? valuesToString(types) : undefined, + unknowns: unknownFilters, + }, + }; +}; + +const valuesToString = (raw: FilterValues): FilterValues => + raw.map((value) => String(value)); diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.test.ts b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.test.ts new file mode 100644 index 00000000000000..c04f5dddd34a26 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Query } from '@elastic/eui'; +import { getSearchTerm, getFieldValueMap, applyAliases } from './query_utils'; +import { FilterValues } from './types'; + +describe('getSearchTerm', () => { + const searchTerm = (raw: string) => getSearchTerm(Query.parse(raw)); + + it('returns the search term when no field is present', () => { + expect(searchTerm('some plain query')).toEqual('some plain query'); + }); + + it('remove leading and trailing spaces', () => { + expect(searchTerm(' hello dolly ')).toEqual('hello dolly'); + }); + + it('remove duplicate whitespaces', () => { + expect(searchTerm(' foo bar ')).toEqual('foo bar'); + }); + + it('omits field terms', () => { + expect(searchTerm('some tag:foo query type:dashboard')).toEqual('some query'); + expect(searchTerm('tag:foo another query type:(dashboard OR vis)')).toEqual('another query'); + }); + + it('remove duplicate whitespaces when using field terms', () => { + expect(searchTerm(' over tag:foo 9000 ')).toEqual('over 9000'); + }); +}); + +describe('getFieldValueMap', () => { + const fieldValueMap = (raw: string) => getFieldValueMap(Query.parse(raw)); + + it('parses single value field term', () => { + const result = fieldValueMap('tag:foo'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo']); + }); + + it('parses multi-value field term', () => { + const result = fieldValueMap('tag:(foo OR bar)'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo', 'bar']); + }); + + it('parses multiple single value field terms', () => { + const result = fieldValueMap('tag:foo tag:bar'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo', 'bar']); + }); + + it('parses boolean field terms', () => { + const result = fieldValueMap('tag:true tag:false'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual([true, false]); + }); + + it('parses numeric field terms', () => { + const result = fieldValueMap('tag:42 tag:9000'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual([42, 9000]); + }); + + it('parses multiple mixed single/multi value field terms', () => { + const result = fieldValueMap('tag:foo tag:(bar OR hello) tag:dolly'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo', 'bar', 'hello', 'dolly']); + }); + + it('parses distinct field terms', () => { + const result = fieldValueMap('tag:foo type:dashboard tag:dolly type:(config OR map) foo:bar'); + + expect(result.size).toBe(3); + expect(result.get('tag')).toEqual(['foo', 'dolly']); + expect(result.get('type')).toEqual(['dashboard', 'config', 'map']); + expect(result.get('foo')).toEqual(['bar']); + }); + + it('ignore the search terms', () => { + const result = fieldValueMap('tag:foo some type:dashboard query foo:bar'); + + expect(result.size).toBe(3); + expect(result.get('tag')).toEqual(['foo']); + expect(result.get('type')).toEqual(['dashboard']); + expect(result.get('foo')).toEqual(['bar']); + }); +}); + +describe('applyAliases', () => { + const getValueMap = (entries: Record) => + new Map([...Object.entries(entries)]); + + it('returns the map unchanged when no aliases are used', () => { + const result = applyAliases( + getValueMap({ + tag: ['tag-1', 'tag-2'], + type: ['dashboard'], + }), + {} + ); + + expect(result.size).toEqual(2); + expect(result.get('tag')).toEqual(['tag-1', 'tag-2']); + expect(result.get('type')).toEqual(['dashboard']); + }); + + it('apply the aliases', () => { + const result = applyAliases( + getValueMap({ + tag: ['tag-1'], + tags: ['tag-2', 'tag-3'], + type: ['dashboard'], + }), + { + tag: ['tags'], + } + ); + + expect(result.size).toEqual(2); + expect(result.get('tag')).toEqual(['tag-1', 'tag-2', 'tag-3']); + expect(result.get('type')).toEqual(['dashboard']); + }); +}); diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.ts b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.ts new file mode 100644 index 00000000000000..93fdd943a202c8 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Query } from '@elastic/eui'; +import { FilterValues } from './types'; + +/** + * Return a name->values map for all the field clauses of given query. + * + * @example + * ``` + * getFieldValueMap(Query.parse('foo:bar foo:baz hello:dolly term')); + * >> { foo: ['bar', 'baz'], hello: ['dolly] } + * ``` + */ +export const getFieldValueMap = (query: Query) => { + const fieldMap = new Map(); + + query.ast.clauses.forEach((clause) => { + if (clause.type === 'field') { + const { field, value } = clause; + fieldMap.set(field, [ + ...(fieldMap.get(field) ?? []), + ...((Array.isArray(value) ? value : [value]) as FilterValues), + ]); + } + }); + + return fieldMap; +}; + +/** + * Aggregate all term clauses from given query and concatenate them. + */ +export const getSearchTerm = (query: Query): string | undefined => { + let term: string | undefined; + if (query.ast.getTermClauses().length) { + term = query.ast + .getTermClauses() + .map((clause) => clause.value) + .join(' ') + .replace(/\s{2,}/g, ' ') + .trim(); + } + return term?.length ? term : undefined; +}; + +/** + * Apply given alias map to the value map, concatenating the aliases values to the alias target, and removing + * the alias entry. Any non-aliased entries will remain unchanged. + * + * @example + * ``` + * applyAliases({ field: ['foo'], alias: ['bar'], hello: ['dolly'] }, { field: ['alias']}); + * >> { field: ['foo', 'bar'], hello: ['dolly'] } + * ``` + */ +export const applyAliases = ( + valueMap: Map, + aliasesMap: Record +): Map => { + const reverseLookup: Record = {}; + Object.entries(aliasesMap).forEach(([canonical, aliases]) => { + aliases.forEach((alias) => { + reverseLookup[alias] = canonical; + }); + }); + + const resultMap = new Map(); + valueMap.forEach((values, field) => { + const targetKey = reverseLookup[field] ?? field; + resultMap.set(targetKey, [...(resultMap.get(targetKey) ?? []), ...values]); + }); + + return resultMap; +}; diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/types.ts b/x-pack/plugins/global_search_bar/public/search_syntax/types.ts new file mode 100644 index 00000000000000..8df025a478bc5e --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/types.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. + */ + +export type FilterValueType = string | boolean | number; + +export type FilterValues = ValueType[]; + +export interface ParsedSearchParams { + /** + * The parsed search term. + * Can be undefined if the query was only composed of field terms. + */ + term?: string; + /** + * The filters extracted from the field terms. + */ + filters: { + /** + * Aggregation of `tag` and `tags` field clauses + */ + tags?: FilterValues; + /** + * Aggregation of `type` and `types` field clauses + */ + types?: FilterValues; + /** + * All unknown field clauses + */ + unknowns: Record; + }; +} diff --git a/x-pack/plugins/global_search_providers/public/providers/application.test.ts b/x-pack/plugins/global_search_providers/public/providers/application.test.ts index 8acbda5e0a6d46..2831550da00d97 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.test.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.ts @@ -61,6 +61,10 @@ describe('applicationResultProvider', () => { getAppResultsMock.mockReturnValue([]); }); + afterEach(() => { + getAppResultsMock.mockReset(); + }); + it('has the correct id', () => { const provider = createApplicationResultProvider(Promise.resolve(application)); expect(provider.id).toBe('application'); @@ -76,7 +80,7 @@ describe('applicationResultProvider', () => { ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledTimes(1); expect(getAppResultsMock).toHaveBeenCalledWith('term', [ @@ -86,6 +90,59 @@ describe('applicationResultProvider', () => { ]); }); + it('calls `getAppResults` when filtering by type with `application` included', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + await provider + .find({ term: 'term', types: ['dashboard', 'application'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledTimes(1); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1'), expectApp('app2')]); + }); + + it('does not call `getAppResults` and return no results when filtering by type with `application` not included', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + const results = await provider + .find({ term: 'term', types: ['dashboard', 'map'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); + + it('does not call `getAppResults` and returns no results when filtering by tag', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + const results = await provider + .find({ term: 'term', tags: ['some-tag-id'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); + it('ignores inaccessible apps', async () => { application.applications$ = of( createAppMap([ @@ -94,7 +151,7 @@ describe('applicationResultProvider', () => { ]) ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); }); @@ -108,7 +165,7 @@ describe('applicationResultProvider', () => { ]) ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); }); @@ -122,7 +179,7 @@ describe('applicationResultProvider', () => { ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); }); @@ -136,7 +193,7 @@ describe('applicationResultProvider', () => { ]); const provider = createApplicationResultProvider(Promise.resolve(application)); - const results = await provider.find('term', defaultOption).toPromise(); + const results = await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(results).toEqual([ expectResult('r100'), @@ -160,7 +217,7 @@ describe('applicationResultProvider', () => { ...defaultOption, maxResults: 2, }; - const results = await provider.find('term', options).toPromise(); + const results = await provider.find({ term: 'term' }, options).toPromise(); expect(results).toEqual([expectResult('r100'), expectResult('r75')]); }); @@ -184,7 +241,7 @@ describe('applicationResultProvider', () => { aborted$: hot('|'), }; - const resultObs = provider.find('term', options); + const resultObs = provider.find({ term: 'term' }, options); expectObservable(resultObs).toBe('--(a|)', { a: [] }); }); @@ -209,7 +266,7 @@ describe('applicationResultProvider', () => { aborted$: hot('-(a|)', { a: undefined }), }; - const resultObs = provider.find('term', options); + const resultObs = provider.find({ term: 'term' }, options); expectObservable(resultObs).toBe('-|'); }); diff --git a/x-pack/plugins/global_search_providers/public/providers/application.ts b/x-pack/plugins/global_search_providers/public/providers/application.ts index 45264a3b2c521a..fd6eb0dc1878b8 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { from } from 'rxjs'; +import { from, of } from 'rxjs'; import { take, map, takeUntil, mergeMap, shareReplay } from 'rxjs/operators'; import { ApplicationStart } from 'src/core/public'; import { GlobalSearchResultProvider } from '../../../global_search/public'; @@ -26,12 +26,15 @@ export const createApplicationResultProvider = ( return { id: 'application', - find: (term, { aborted$, maxResults }) => { + find: ({ term, types, tags }, { aborted$, maxResults }) => { + if (tags || (types && !types.includes('application'))) { + return of([]); + } return searchableApps$.pipe( takeUntil(aborted$), take(1), map((apps) => { - const results = getAppResults(term, [...apps.values()]); + const results = getAppResults(term ?? '', [...apps.values()]); return results.sort((a, b) => b.score - a.score).slice(0, maxResults); }) ); diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts index 8798fe6694c96c..ca5dbf8026472d 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts @@ -42,6 +42,7 @@ describe('mapToResult', () => { name: 'dashboard', management: { defaultSearchField: 'title', + icon: 'dashboardApp', getInAppUrl: (obj) => ({ path: `/dashboard/${obj.id}`, uiCapabilitiesPath: '' }), }, }); @@ -62,6 +63,7 @@ describe('mapToResult', () => { title: 'My dashboard', type: 'dashboard', url: '/dashboard/dash1', + icon: 'dashboardApp', score: 42, }); }); diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts index 14641e1aaffff4..ec55a2a78fa9e1 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts @@ -50,6 +50,7 @@ export const mapToResult = ( // so we are forced to cast the attributes to any to access the properties associated with it. title: (object.attributes as any)[defaultSearchField], type: object.type, + icon: type.management?.icon ?? undefined, url: getInAppUrl(object).path, score: object.score, }; diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts index b556e2785b4b4a..da9276278dbbf9 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts @@ -116,7 +116,7 @@ describe('savedObjectsResultProvider', () => { }); it('calls `savedObjectClient.find` with the correct parameters', async () => { - await provider.find('term', defaultOption, context).toPromise(); + await provider.find({ term: 'term' }, defaultOption, context).toPromise(); expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ @@ -129,8 +129,56 @@ describe('savedObjectsResultProvider', () => { }); }); - it('does not call `savedObjectClient.find` if `term` is empty', async () => { - const results = await provider.find('', defaultOption, context).pipe(toArray()).toPromise(); + it('filters searchable types depending on the `types` parameter', async () => { + await provider.find({ term: 'term', types: ['typeA'] }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title'], + type: ['typeA'], + }); + }); + + it('ignore the case for the `types` parameter', async () => { + await provider.find({ term: 'term', types: ['TyPEa'] }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title'], + type: ['typeA'], + }); + }); + + it('calls `savedObjectClient.find` with the correct references when the `tags` option is set', async () => { + await provider + .find({ term: 'term', tags: ['tag-id-1', 'tag-id-2'] }, defaultOption, context) + .toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title', 'description'], + hasReference: [ + { type: 'tag', id: 'tag-id-1' }, + { type: 'tag', id: 'tag-id-2' }, + ], + type: ['typeA', 'typeB'], + }); + }); + + it('does not call `savedObjectClient.find` if all params are empty', async () => { + const results = await provider.find({}, defaultOption, context).pipe(toArray()).toPromise(); expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); expect(results).toEqual([[]]); @@ -144,7 +192,7 @@ describe('savedObjectsResultProvider', () => { ]) ); - const results = await provider.find('term', defaultOption, context).toPromise(); + const results = await provider.find({ term: 'term' }, defaultOption, context).toPromise(); expect(results).toEqual([ { id: 'resultA', @@ -172,7 +220,7 @@ describe('savedObjectsResultProvider', () => { ); const resultObs = provider.find( - 'term', + { term: 'term' }, { ...defaultOption, aborted$: hot('-(a|)', { a: undefined }) }, context ); diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts index 3861858a536268..3e2c42e7896fda 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts @@ -6,14 +6,15 @@ import { from, combineLatest, of } from 'rxjs'; import { map, takeUntil, first } from 'rxjs/operators'; +import { SavedObjectsFindOptionsReference } from 'src/core/server'; import { GlobalSearchResultProvider } from '../../../../global_search/server'; import { mapToResults } from './map_object_to_result'; export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider => { return { id: 'savedObjects', - find: (term, { aborted$, maxResults, preference }, { core }) => { - if (!term) { + find: ({ term, types, tags }, { aborted$, maxResults, preference }, { core }) => { + if (!term && !types && !tags) { return of([]); } @@ -24,15 +25,22 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = const searchableTypes = typeRegistry .getVisibleTypes() + .filter(types ? (type) => includeIgnoreCase(types, type.name) : () => true) .filter((type) => type.management?.defaultSearchField && type.management?.getInAppUrl); + const searchFields = uniq( searchableTypes.map((type) => type.management!.defaultSearchField!) ); + const references: SavedObjectsFindOptionsReference[] | undefined = tags + ? tags.map((tagId) => ({ type: 'tag', id: tagId })) + : undefined; + const responsePromise = client.find({ page: 1, perPage: maxResults, search: term ? `${term}*` : undefined, + ...(references ? { hasReference: references } : {}), preference, searchFields, type: searchableTypes.map((type) => type.name), @@ -47,3 +55,6 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = }; const uniq = (values: T[]): T[] => [...new Set(values)]; + +const includeIgnoreCase = (list: string[], item: string) => + list.find((e) => e.toLowerCase() === item.toLowerCase()) !== undefined; diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index 116345b35fdcea..f1052672978d5e 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -11,7 +11,10 @@ import type { UsageCollectionSetup, UsageCollectionStart, } from '../../../../src/plugins/usage_collection/public'; -import type { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; +import type { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, +} from '../../../plugins/triggers_actions_ui/public'; import type { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; import type { ObservabilityPluginSetup, @@ -37,7 +40,7 @@ export interface InfraClientStartDeps { dataEnhanced: DataEnhancedStart; observability: ObservabilityPluginStart; spaces: SpacesPluginStart; - triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; usageCollection: UsageCollectionStart; ml: MlPluginStart; } diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index a3b4cb604231f5..ef09dbfcb2674b 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -88,7 +88,7 @@ export class InfraServerPlugin { } async setup(core: CoreSetup, plugins: InfraServerPluginDeps) { - await new Promise((resolve) => { + await new Promise((resolve) => { this.config$.subscribe((configValue) => { this.config = configValue; resolve(); diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index ce78757676bccb..5476be50fee88c 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -14,7 +14,8 @@ "dashboard", "charts", "uiActions", - "embeddable" + "embeddable", + "share" ], "optionalPlugins": ["usageCollection", "taskManager", "globalSearch", "savedObjectsTagging"], "configPath": ["xpack", "lens"], diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index a211416472f48b..6eef961a52e9b1 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -308,6 +308,9 @@ describe('Lens App', () => { const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; const pinnedFilter = esFilters.buildExistsFilter(pinnedField, indexPattern); services.data.query.filterManager.getFilters = jest.fn().mockImplementation(() => { + return []; + }); + services.data.query.filterManager.getGlobalFilters = jest.fn().mockImplementation(() => { return [pinnedFilter]; }); const { component, frame } = mountWith({ services }); @@ -322,6 +325,7 @@ describe('Lens App', () => { filters: [pinnedFilter], }) ); + expect(services.data.query.filterManager.getFilters).not.toHaveBeenCalled(); }); it('displays errors from the frame in a toast', () => { @@ -895,6 +899,71 @@ describe('Lens App', () => { }); }); + describe('download button', () => { + function getButton(inst: ReactWrapper): TopNavMenuData { + return (inst + .find('[data-test-subj="lnsApp_topNav"]') + .prop('config') as TopNavMenuData[]).find( + (button) => button.testId === 'lnsApp_downloadCSVButton' + )!; + } + + it('should be disabled when no data is available', async () => { + const { component, frame } = mountWith({}); + const onChange = frame.mount.mock.calls[0][1].onChange; + await act(async () => + onChange({ + filterableIndexPatterns: [], + doc: ({} as unknown) as Document, + isSaveable: true, + }) + ); + component.update(); + expect(getButton(component).disableButton).toEqual(true); + }); + + it('should disable download when not saveable', async () => { + const { component, frame } = mountWith({}); + const onChange = frame.mount.mock.calls[0][1].onChange; + + await act(async () => + onChange({ + filterableIndexPatterns: [], + doc: ({} as unknown) as Document, + isSaveable: false, + activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, + }) + ); + + component.update(); + expect(getButton(component).disableButton).toEqual(true); + }); + + it('should still be enabled even if the user is missing save permissions', async () => { + const services = makeDefaultServices(); + services.application = { + ...services.application, + capabilities: { + ...services.application.capabilities, + visualize: { save: false, saveQuery: false, show: true }, + }, + }; + + const { component, frame } = mountWith({ services }); + const onChange = frame.mount.mock.calls[0][1].onChange; + await act(async () => + onChange({ + filterableIndexPatterns: [], + doc: ({} as unknown) as Document, + isSaveable: true, + activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, + }) + ); + component.update(); + expect(getButton(component).disableButton).toEqual(false); + }); + }); + describe('query bar state management', () => { it('uses the default time and query language settings', () => { const { frame } = mountWith({}); diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index cdd701271be2ca..3066f85bbf3f99 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -11,6 +11,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { NotificationsStart } from 'kibana/public'; import { EuiBreadcrumb } from '@elastic/eui'; +import { downloadMultipleAs } from '../../../../../src/plugins/share/public'; import { createKbnUrlStateStorage, withNotifyOnErrors, @@ -25,6 +26,7 @@ import { NativeRenderer } from '../native_renderer'; import { trackUiEvent } from '../lens_ui_telemetry'; import { esFilters, + exporters, IndexPattern as IndexPatternInstance, IndexPatternsContract, syncQueryStateWithUrl, @@ -70,7 +72,11 @@ export function App({ const currentRange = data.query.timefilter.timefilter.getTime(); return { query: data.query.queryString.getQuery(), - filters: data.query.filterManager.getFilters(), + // Do not use app-specific filters from previous app, + // only if Lens was opened with the intention to visualize a field (e.g. coming from Discover) + filters: !initialContext + ? data.query.filterManager.getGlobalFilters() + : data.query.filterManager.getFilters(), isLoading: Boolean(initialInput), indexPatternsForTopNav: [], dateRange: { @@ -474,16 +480,50 @@ export function App({ const { TopNavMenu } = navigation.ui; const savingPermitted = Boolean(state.isSaveable && application.capabilities.visualize.save); + const unsavedTitle = i18n.translate('xpack.lens.app.unsavedFilename', { + defaultMessage: 'unsaved', + }); const topNavConfig = getLensTopNavConfig({ showSaveAndReturn: Boolean( state.isLinkedToOriginatingApp && // Temporarily required until the 'by value' paradigm is default. (dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput)) ), + enableExportToCSV: Boolean( + state.isSaveable && state.activeData && Object.keys(state.activeData).length + ), isByValueMode: getIsByValueMode(), showCancel: Boolean(state.isLinkedToOriginatingApp), savingPermitted, actions: { + exportToCSV: () => { + if (!state.activeData) { + return; + } + const datatables = Object.values(state.activeData); + const content = datatables.reduce>( + (memo, datatable, i) => { + // skip empty datatables + if (datatable) { + const postFix = datatables.length > 1 ? `-${i + 1}` : ''; + + memo[`${lastKnownDoc?.title || unsavedTitle}${postFix}.csv`] = { + content: exporters.datatableToCSV(datatable, { + csvSeparator: uiSettings.get('csv:separator', ','), + quoteValues: uiSettings.get('csv:quoteValues', true), + formatFactory: data.fieldFormats.deserialize, + }), + type: exporters.CSV_MIME_TYPE, + }; + } + return memo; + }, + {} + ); + if (content) { + downloadMultipleAs(content); + } + }, saveAndReturn: () => { if (savingPermitted && lastKnownDoc) { // disabling the validation on app leave because the document has been saved. @@ -605,13 +645,16 @@ export function App({ onError, showNoDataPopover, initialContext, - onChange: ({ filterableIndexPatterns, doc, isSaveable }) => { + onChange: ({ filterableIndexPatterns, doc, isSaveable, activeData }) => { if (isSaveable !== state.isSaveable) { setState((s) => ({ ...s, isSaveable })); } if (!_.isEqual(state.persistedDoc, doc)) { setState((s) => ({ ...s, lastKnownDoc: doc })); } + if (!_.isEqual(state.activeData, activeData)) { + setState((s) => ({ ...s, activeData })); + } // Update the cached index patterns if the user made a change to any of them if ( diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index 9162af52052ee9..2c23dc291405cc 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -10,12 +10,13 @@ import { LensTopNavActions } from './types'; export function getLensTopNavConfig(options: { showSaveAndReturn: boolean; + enableExportToCSV: boolean; showCancel: boolean; isByValueMode: boolean; actions: LensTopNavActions; savingPermitted: boolean; }): TopNavMenuData[] { - const { showSaveAndReturn, showCancel, actions, savingPermitted } = options; + const { showSaveAndReturn, showCancel, actions, savingPermitted, enableExportToCSV } = options; const topNavMenu: TopNavMenuData[] = []; const saveButtonLabel = options.isByValueMode @@ -30,6 +31,18 @@ export function getLensTopNavConfig(options: { defaultMessage: 'Save', }); + topNavMenu.push({ + label: i18n.translate('xpack.lens.app.downloadCSV', { + defaultMessage: 'Download as CSV', + }), + run: actions.exportToCSV, + testId: 'lnsApp_downloadCSVButton', + description: i18n.translate('xpack.lens.app.downloadButtonAriaLabel', { + defaultMessage: 'Download the data as CSV file', + }), + disableButton: !enableExportToCSV, + }); + if (showCancel) { topNavMenu.push({ label: i18n.translate('xpack.lens.app.cancel', { diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index 6c222bed7a83fe..07dc69078e337c 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -34,6 +34,7 @@ import { ACTION_VISUALIZE_LENS_FIELD, } from '../../../../../src/plugins/ui_actions/public'; import { EmbeddableEditorState } from '../../../../../src/plugins/embeddable/public'; +import { TableInspectorAdapter } from '../editor_frame_service/types'; import { EditorFrameInstance } from '..'; export interface LensAppState { @@ -60,6 +61,7 @@ export interface LensAppState { filters: Filter[]; savedQuery?: SavedQuery; isSaveable: boolean; + activeData?: TableInspectorAdapter; } export interface RedirectToOriginProps { @@ -111,4 +113,5 @@ export interface LensTopNavActions { saveAndReturn: () => void; showSaveModal: () => void; cancel: () => void; + exportToCSV: () => void; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index 6b7e5ba8ea89d8..93b4a4e3bea207 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -19,10 +19,9 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config const activeVisualization = props.visualizationMap[props.activeVisualizationId || '']; const { visualizationState } = props; - return ( - activeVisualization && - visualizationState && - ); + return activeVisualization && visualizationState ? ( + + ) : null; }); function LayerPanels( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index f5b31fb8811672..67c6068dd4d91c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -37,9 +37,9 @@ function isConfiguration( value: unknown ): value is { columnId: string; groupId: string; layerId: string } { return ( - value && + Boolean(value) && typeof value === 'object' && - 'columnId' in value && + 'columnId' in value! && 'groupId' in value && 'layerId' in value ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 935d65bfb6c08b..fea9723aa700d4 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -244,6 +244,7 @@ export function EditorFrame(props: EditorFrameProps) { activeVisualization, state.datasourceStates, state.visualization, + state.activeData, props.query, props.dateRange, props.filters, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts index 4cb523f128a8ce..eec3f68ced5fcd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts @@ -6,6 +6,7 @@ import _ from 'lodash'; import { SavedObjectReference } from 'kibana/public'; +import { Datatable } from 'src/plugins/expressions'; import { EditorFrameState } from './state_management'; import { Document } from '../../persistence/saved_object_store'; import { Datasource, Visualization, FramePublicAPI } from '../../types'; @@ -28,6 +29,7 @@ export function getSavedObjectFormat({ doc: Document; filterableIndexPatterns: string[]; isSaveable: boolean; + activeData: Record | undefined; } { const datasourceStates: Record = {}; const references: SavedObjectReference[] = []; @@ -74,5 +76,6 @@ export function getSavedObjectFormat({ }, filterableIndexPatterns: uniqueFilterableIndexPatternIds, isSaveable: expression !== null, + activeData: state.activeData, }; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 0c96fc45de1284..9c5eafc300abc0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -110,21 +110,21 @@ export const validateDatasourceAndVisualization = ( longMessage: string; }> | undefined => { - const layersGroups = - currentVisualizationState && - currentVisualization - ?.getLayerIds(currentVisualizationState) - .reduce>((memo, layerId) => { - const groups = currentVisualization?.getConfiguration({ - frame: frameAPI, - layerId, - state: currentVisualizationState, - }).groups; - if (groups) { - memo[layerId] = groups; - } - return memo; - }, {}); + const layersGroups = currentVisualizationState + ? currentVisualization + ?.getLayerIds(currentVisualizationState) + .reduce>((memo, layerId) => { + const groups = currentVisualization?.getConfiguration({ + frame: frameAPI, + layerId, + state: currentVisualizationState, + }).groups; + if (groups) { + memo[layerId] = groups; + } + return memo; + }, {}) + : undefined; const datasourceValidationErrors = currentDatasourceState ? currentDataSource?.getErrorMessages(currentDatasourceState, layersGroups) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts index e0101493b27aab..55a4cb567fda1d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts @@ -148,7 +148,7 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta case 'UPDATE_ACTIVE_DATA': return { ...state, - activeData: action.tables, + activeData: { ...action.tables }, }; case 'UPDATE_LAYER': return { diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index 9f9d7fef9c7b4f..3a3258a79c59f8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -262,6 +262,45 @@ describe('embeddable', () => { expect(expressionRenderer.mock.calls[0][0].searchSessionId).toBe(input.searchSessionId); }); + it('should pass render mode to expression', async () => { + const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; + const query: Query = { language: 'kquery', query: '' }; + const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }]; + + const input = { + savedObjectId: '123', + timeRange, + query, + filters, + renderMode: 'noInteractivity', + } as LensEmbeddableInput; + + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: {} as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), + }, + input + ); + await embeddable.initializeSavedVis(input); + embeddable.render(mountpoint); + + expect(expressionRenderer.mock.calls[0][0].renderMode).toEqual('noInteractivity'); + }); + it('should merge external context with query and filters of the saved object', async () => { const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; const query: Query = { language: 'kquery', query: 'external filter' }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index 8139631daa971a..76276f8b4c8281 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -20,6 +20,7 @@ import { PaletteOutput } from 'src/plugins/charts/public'; import { Subscription } from 'rxjs'; import { toExpression, Ast } from '@kbn/interpreter/common'; +import { RenderMode } from 'src/plugins/expressions'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -53,6 +54,7 @@ export type LensByValueInput = { export type LensByReferenceInput = SavedObjectEmbeddableInput & EmbeddableInput; export type LensEmbeddableInput = (LensByValueInput | LensByReferenceInput) & { palette?: PaletteOutput; + renderMode?: RenderMode; }; export interface LensEmbeddableOutput extends EmbeddableOutput { @@ -192,6 +194,7 @@ export class Embeddable variables={input.palette ? { theme: { palette: input.palette } } : {}} searchSessionId={this.input.searchSessionId} handleEvent={this.handleEvent} + renderMode={input.renderMode} />, domNode ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index 4a3ba971381fb3..d18372246b0e62 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -13,6 +13,7 @@ import { ReactExpressionRendererType, } from 'src/plugins/expressions/public'; import { ExecutionContextSearch } from 'src/plugins/data/public'; +import { RenderMode } from 'src/plugins/expressions'; import { getOriginalRequestErrorMessage } from '../error_helper'; export interface ExpressionWrapperProps { @@ -22,6 +23,7 @@ export interface ExpressionWrapperProps { searchContext: ExecutionContextSearch; searchSessionId?: string; handleEvent: (event: ExpressionRendererEvent) => void; + renderMode?: RenderMode; } export function ExpressionWrapper({ @@ -31,6 +33,7 @@ export function ExpressionWrapper({ variables, handleEvent, searchSessionId, + renderMode, }: ExpressionWrapperProps) { return ( @@ -57,6 +60,7 @@ export function ExpressionWrapper({ expression={expression} searchContext={searchContext} searchSessionId={searchSessionId} + renderMode={renderMode} renderError={(errorMessage, error) => (
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx index 3c9e19d30d38fc..25cb34d19beb8d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx @@ -6,8 +6,7 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { EuiPopover, EuiPopoverTitle, EuiSelectable } from '@elastic/eui'; -import { EuiSelectableProps } from '@elastic/eui/src/components/selectable/selectable'; +import { EuiPopover, EuiPopoverTitle, EuiSelectable, EuiSelectableProps } from '@elastic/eui'; import { IndexPatternRef } from './types'; import { trackUiEvent } from '../lens_ui_telemetry'; import { ToolbarButtonProps, ToolbarButton } from '../shared_components'; @@ -63,7 +62,12 @@ export function ChangeIndexPattern({ defaultMessage: 'Change index pattern', })} - {...selectableProps} searchable singleSelection="always" diff --git a/x-pack/plugins/lens/public/pie_visualization/expression.tsx b/x-pack/plugins/lens/public/pie_visualization/expression.tsx index 3b5226eaa8e1fa..5f18ef7c7f6373 100644 --- a/x-pack/plugins/lens/public/pie_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/expression.tsx @@ -139,6 +139,7 @@ export const getPieRenderer = (dependencies: { chartsThemeService={dependencies.chartsThemeService} paletteService={dependencies.paletteService} onClickValue={onClickValue} + renderMode={handlers.getRenderMode()} /> , domNode, diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index c44179ccd8dfc4..458b1a75c4c171 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -70,6 +70,7 @@ describe('PieVisualization component', () => { onClickValue: jest.fn(), chartsThemeService, paletteService: chartPluginMock.createPaletteRegistry(), + renderMode: 'display' as const, }; } @@ -266,6 +267,14 @@ describe('PieVisualization component', () => { `); }); + test('does not set click listener on noInteractivity render mode', () => { + const defaultArgs = getDefaultArgs(); + const component = shallow( + + ); + expect(component.find(Settings).first().prop('onElementClick')).toBeUndefined(); + }); + test('it shows emptyPlaceholder for undefined grouped data', () => { const defaultData = getDefaultArgs().data; const emptyData: LensMultiTable = { diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index 39743a355fd78b..20d558fefc3d75 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -20,7 +20,9 @@ import { RecursivePartial, Position, Settings, + ElementClickListener, } from '@elastic/charts'; +import { RenderMode } from 'src/plugins/expressions'; import { FormatFactory, LensFilterEvent } from '../types'; import { VisualizationContainer } from '../visualization_container'; import { CHART_NAMES, DEFAULT_PERCENT_DECIMALS } from './constants'; @@ -44,6 +46,7 @@ export function PieComponent( chartsThemeService: ChartsPluginSetup['theme']; paletteService: PaletteRegistry; onClickValue: (data: LensFilterEvent['data']) => void; + renderMode: RenderMode; } ) { const [firstTable] = Object.values(props.data.tables); @@ -228,6 +231,12 @@ export function PieComponent( ); } + + const onElementClickHandler: ElementClickListener = (args) => { + const context = getFilterContext(args[0][0] as LayerValue[], groups, firstTable); + + onClickValue(desanitizeFilterContext(context)); + }; return ( { - const context = getFilterContext(args[0][0] as LayerValue[], groups, firstTable); - - onClickValue(desanitizeFilterContext(context)); - }} + onElementClick={ + props.renderMode !== 'noInteractivity' ? onElementClickHandler : undefined + } theme={{ ...chartTheme, background: { diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index 3097c40663132c..c0393a7e488651 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -7,6 +7,7 @@ import { PaletteOutput } from 'src/plugins/charts/public'; import { DataType } from '../types'; import { suggestions } from './suggestions'; +import { PieVisualizationState } from './types'; describe('suggestions', () => { describe('pie', () => { @@ -82,7 +83,7 @@ describe('suggestions', () => { ).toHaveLength(0); }); - it('should reject any date operations', () => { + it('should reject date operations', () => { expect( suggestions({ table: { @@ -111,7 +112,7 @@ describe('suggestions', () => { ).toHaveLength(0); }); - it('should reject any histogram operations', () => { + it('should reject histogram operations', () => { expect( suggestions({ table: { @@ -140,7 +141,7 @@ describe('suggestions', () => { ).toHaveLength(0); }); - it('should reject when there are no buckets', () => { + it('should reject when there are too many buckets', () => { expect( suggestions({ table: { @@ -148,28 +149,24 @@ describe('suggestions', () => { isMultiRow: true, columns: [ { - columnId: 'c', - operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, }, - ], - changeType: 'initial', - }, - state: undefined, - keptLayerIds: ['first'], - }) - ).toHaveLength(0); - }); - - it('should reject when there are no metrics', () => { - expect( - suggestions({ - table: { - layerId: 'first', - isMultiRow: true, - columns: [ { columnId: 'c', - operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: true }, + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, }, ], changeType: 'initial', @@ -180,7 +177,7 @@ describe('suggestions', () => { ).toHaveLength(0); }); - it('should reject when there are too many buckets', () => { + it('should reject when there are too many metrics', () => { expect( suggestions({ table: { @@ -201,7 +198,7 @@ describe('suggestions', () => { }, { columnId: 'd', - operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, }, { columnId: 'e', @@ -216,42 +213,86 @@ describe('suggestions', () => { ).toHaveLength(0); }); - it('should reject when there are too many metrics', () => { + it('should reject if there are no buckets and it is not a specific chart type switch', () => { expect( suggestions({ table: { layerId: 'first', isMultiRow: true, columns: [ - { - columnId: 'a', - operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, - }, - { - columnId: 'b', - operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, - }, { columnId: 'c', - operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, - }, - { - columnId: 'd', - operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, }, + ], + changeType: 'initial', + }, + state: {} as PieVisualizationState, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject if there are no metrics and it is not a specific chart type switch', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ { - columnId: 'e', - operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: true }, }, ], changeType: 'initial', }, - state: undefined, + state: {} as PieVisualizationState, keptLayerIds: ['first'], }) ).toHaveLength(0); }); + it('should hide suggestions when there are no buckets', () => { + const currentSuggestions = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }); + expect(currentSuggestions).toHaveLength(3); + expect(currentSuggestions.every((s) => s.hide)).toEqual(true); + }); + + it('should hide suggestions when there are no metrics', () => { + const currentSuggestions = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: true }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }); + expect(currentSuggestions).toHaveLength(3); + expect(currentSuggestions.every((s) => s.hide)).toEqual(true); + }); + it('should suggest a donut chart as initial state when only one bucket', () => { const results = suggestions({ table: { diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index 497fb2e7de840d..5eacb118b27df1 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -24,6 +24,7 @@ export function suggestions({ state, keptLayerIds, mainPalette, + subVisualizationId, }: SuggestionRequest): Array< VisualizationSuggestion > { @@ -33,11 +34,17 @@ export function suggestions({ const [groups, metrics] = partition(table.columns, (col) => col.operation.isBucketed); - if ( - groups.length === 0 || - metrics.length !== 1 || - groups.length > Math.max(MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS) - ) { + if (metrics.length > 1 || groups.length > Math.max(MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS)) { + return []; + } + + const incompleteConfiguration = metrics.length === 0 || groups.length === 0; + const metricColumnId = metrics.length > 0 ? metrics[0].columnId : undefined; + + if (incompleteConfiguration && state && !subVisualizationId) { + // reject incomplete configurations if the sub visualization isn't specifically requested + // this allows to switch chart types via switcher with incomplete configurations, but won't + // cause incomplete suggestions getting auto applied on dropped fields return []; } @@ -65,12 +72,12 @@ export function suggestions({ ...state.layers[0], layerId: table.layerId, groups: groups.map((col) => col.columnId), - metric: metrics[0].columnId, + metric: metricColumnId, } : { layerId: table.layerId, groups: groups.map((col) => col.columnId), - metric: metrics[0].columnId, + metric: metricColumnId, numberDisplay: 'percent', categoryDisplay: 'default', legendDisplay: 'default', @@ -117,7 +124,7 @@ export function suggestions({ ...state.layers[0], layerId: table.layerId, groups: groups.map((col) => col.columnId), - metric: metrics[0].columnId, + metric: metricColumnId, categoryDisplay: state.layers[0].categoryDisplay === 'inside' ? 'default' @@ -126,7 +133,7 @@ export function suggestions({ : { layerId: table.layerId, groups: groups.map((col) => col.columnId), - metric: metrics[0].columnId, + metric: metricColumnId, numberDisplay: 'percent', categoryDisplay: 'default', legendDisplay: 'default', @@ -140,5 +147,10 @@ export function suggestions({ }); } - return [...results].sort((a, b) => a.score - b.score); + return [...results] + .sort((a, b) => a.score - b.score) + .map((suggestion) => ({ + ...suggestion, + hide: incompleteConfiguration || suggestion.hide, + })); } diff --git a/x-pack/plugins/lens/public/search_provider.ts b/x-pack/plugins/lens/public/search_provider.ts index c19e7970b45ae6..02b7900a4c0039 100644 --- a/x-pack/plugins/lens/public/search_provider.ts +++ b/x-pack/plugins/lens/public/search_provider.ts @@ -6,7 +6,7 @@ import levenshtein from 'js-levenshtein'; import { ApplicationStart } from 'kibana/public'; -import { from } from 'rxjs'; +import { from, of } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { GlobalSearchResultProvider } from '../../global_search/public'; @@ -26,7 +26,10 @@ export const getSearchProvider: ( uiCapabilities: Promise ) => GlobalSearchResultProvider = (uiCapabilities) => ({ id: 'lens', - find: (term) => { + find: ({ term = '', types, tags }) => { + if (tags || (types && !types.includes('application'))) { + return of([]); + } return from( uiCapabilities.then(({ navLinks: { visualize: visualizeNavLink } }) => { if (!visualizeNavLink) { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 225fedb987c76a..2f40f21455310d 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -50,6 +50,7 @@ export interface EditorFrameProps { filterableIndexPatterns: string[]; doc: Document; isSaveable: boolean; + activeData?: Record; }) => void; showNoDataPopover: () => void; } diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index a4b5d741c80f1f..0e2b47410c3f9f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -427,6 +427,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -451,6 +452,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'line' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -504,6 +506,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={undefined} @@ -541,6 +544,7 @@ describe('xy_expression', () => { args={multiLayerArgs} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -578,6 +582,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -596,6 +601,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'bar' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -617,6 +623,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'area' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -638,6 +645,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'bar_horizontal' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -664,6 +672,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -688,6 +697,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -773,6 +783,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -791,6 +802,27 @@ describe('xy_expression', () => { }); }); + test('onBrushEnd is not set on noInteractivity mode', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(Settings).first().prop('onBrushEnd')).toBeUndefined(); + }); + test('onElementClick returns correct context data', () => { const geometry: GeometryValue = { x: 5, y: 1, accessor: 'y1', mark: null, datum: {} }; const series = { @@ -825,6 +857,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -855,6 +888,27 @@ describe('xy_expression', () => { }); }); + test('onElementClick is not triggering event on noInteractivity mode', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(Settings).first().prop('onElementClick')).toBeUndefined(); + }); + test('it renders stacked bar', () => { const { data, args } = sampleArgs(); const component = shallow( @@ -863,6 +917,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'bar_stacked' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -884,6 +939,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'area_stacked' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -908,6 +964,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -941,6 +998,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -961,6 +1019,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="CEST" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -987,6 +1046,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [firstLayer] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1007,6 +1067,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [firstLayer] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1030,6 +1091,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [firstLayer, secondLayer] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1058,6 +1120,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1080,6 +1143,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1481,6 +1545,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], xScaleType: 'ordinal' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1501,6 +1566,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], yScaleType: 'sqrt' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1521,6 +1587,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1544,6 +1611,7 @@ describe('xy_expression', () => { paletteService={paletteService} minInterval={50} timeZone="UTC" + renderMode="display" onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1563,6 +1631,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1598,6 +1667,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1631,6 +1701,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1664,6 +1735,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1697,6 +1769,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1797,6 +1870,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1871,6 +1945,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1943,6 +2018,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1967,6 +2043,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1990,6 +2067,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2013,6 +2091,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2048,6 +2127,7 @@ describe('xy_expression', () => { args={{ ...args, fittingFunction: 'Carry' }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2075,6 +2155,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2097,6 +2178,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2124,6 +2206,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2157,6 +2240,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 54ae3bb759d2ca..790416a6c920d7 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -21,6 +21,8 @@ import { StackMode, VerticalAlignment, HorizontalAlignment, + ElementClickListener, + BrushEndListener, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { @@ -31,6 +33,7 @@ import { } from 'src/plugins/expressions/public'; import { IconType } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { RenderMode } from 'src/plugins/expressions'; import { LensMultiTable, FormatFactory, @@ -81,6 +84,7 @@ type XYChartRenderProps = XYChartProps & { minInterval: number | undefined; onClickValue: (data: LensFilterEvent['data']) => void; onSelectRange: (data: LensBrushEvent['data']) => void; + renderMode: RenderMode; }; export const xyChart: ExpressionFunctionDefinition< @@ -235,6 +239,7 @@ export const getXyChartRenderer = (dependencies: { minInterval={await calculateMinInterval(config, dependencies.getIntervalByColumn)} onClickValue={onClickValue} onSelectRange={onSelectRange} + renderMode={handlers.getRenderMode()} /> , domNode, @@ -303,6 +308,7 @@ export function XYChart({ minInterval, onClickValue, onSelectRange, + renderMode, }: XYChartRenderProps) { const { legend, layers, fittingFunction, gridlinesVisibilitySettings, valueLabels } = args; const chartTheme = chartsThemeService.useChartsTheme(); @@ -415,6 +421,87 @@ export function XYChart({ const colorAssignments = getColorAssignments(args.layers, data, formatFactory); + const clickHandler: ElementClickListener = ([[geometry, series]]) => { + // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue + const xySeries = series as XYChartSeriesIdentifier; + const xyGeometry = geometry as GeometryValue; + + const layer = filteredLayers.find((l) => + xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) + ); + if (!layer) { + return; + } + + const table = data.tables[layer.layerId]; + + const points = [ + { + row: table.rows.findIndex((row) => { + if (layer.xAccessor) { + if (layersAlreadyFormatted[layer.xAccessor]) { + // stringify the value to compare with the chart value + return xAxisFormatter.convert(row[layer.xAccessor]) === xyGeometry.x; + } + return row[layer.xAccessor] === xyGeometry.x; + } + }), + column: table.columns.findIndex((col) => col.id === layer.xAccessor), + value: xyGeometry.x, + }, + ]; + + if (xySeries.seriesKeys.length > 1) { + const pointValue = xySeries.seriesKeys[0]; + + points.push({ + row: table.rows.findIndex( + (row) => layer.splitAccessor && row[layer.splitAccessor] === pointValue + ), + column: table.columns.findIndex((col) => col.id === layer.splitAccessor), + value: pointValue, + }); + } + + const xAxisFieldName = table.columns.find((el) => el.id === layer.xAccessor)?.meta?.field; + const timeFieldName = xDomain && xAxisFieldName; + + const context: LensFilterEvent['data'] = { + data: points.map((point) => ({ + row: point.row, + column: point.column, + value: point.value, + table, + })), + timeFieldName, + }; + onClickValue(desanitizeFilterContext(context)); + }; + + const brushHandler: BrushEndListener = ({ x }) => { + if (!x) { + return; + } + const [min, max] = x; + if (!xAxisColumn || !isHistogramViz) { + return; + } + + const table = data.tables[filteredLayers[0].layerId]; + + const xAxisColumnIndex = table.columns.findIndex((el) => el.id === filteredLayers[0].xAccessor); + + const timeFieldName = isTimeViz ? table.columns[xAxisColumnIndex]?.meta?.field : undefined; + + const context: LensBrushEvent['data'] = { + range: [min, max], + table, + column: xAxisColumnIndex, + timeFieldName, + }; + onSelectRange(context); + }; + return ( { - if (!x) { - return; - } - const [min, max] = x; - if (!xAxisColumn || !isHistogramViz) { - return; - } - - const table = data.tables[filteredLayers[0].layerId]; - - const xAxisColumnIndex = table.columns.findIndex( - (el) => el.id === filteredLayers[0].xAccessor - ); - - const timeFieldName = isTimeViz - ? table.columns[xAxisColumnIndex]?.meta?.field - : undefined; - - const context: LensBrushEvent['data'] = { - range: [min, max], - table, - column: xAxisColumnIndex, - timeFieldName, - }; - onSelectRange(context); - }} - onElementClick={([[geometry, series]]) => { - // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue - const xySeries = series as XYChartSeriesIdentifier; - const xyGeometry = geometry as GeometryValue; - - const layer = filteredLayers.find((l) => - xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) - ); - if (!layer) { - return; - } - - const table = data.tables[layer.layerId]; - - const points = [ - { - row: table.rows.findIndex((row) => { - if (layer.xAccessor) { - if (layersAlreadyFormatted[layer.xAccessor]) { - // stringify the value to compare with the chart value - return xAxisFormatter.convert(row[layer.xAccessor]) === xyGeometry.x; - } - return row[layer.xAccessor] === xyGeometry.x; - } - }), - column: table.columns.findIndex((col) => col.id === layer.xAccessor), - value: xyGeometry.x, - }, - ]; - - if (xySeries.seriesKeys.length > 1) { - const pointValue = xySeries.seriesKeys[0]; - - points.push({ - row: table.rows.findIndex( - (row) => layer.splitAccessor && row[layer.splitAccessor] === pointValue - ), - column: table.columns.findIndex((col) => col.id === layer.splitAccessor), - value: pointValue, - }); - } - - const xAxisFieldName = table.columns.find((el) => el.id === layer.xAccessor)?.meta?.field; - const timeFieldName = xDomain && xAxisFieldName; - - const context: LensFilterEvent['data'] = { - data: points.map((point) => ({ - row: point.row, - column: point.column, - value: point.value, - table, - })), - timeFieldName, - }; - onClickValue(desanitizeFilterContext(context)); - }} + onBrushEnd={renderMode !== 'noInteractivity' ? brushHandler : undefined} + onElementClick={renderMode !== 'noInteractivity' ? clickHandler : undefined} /> { jest.resetAllMocks(); }); - test('ignores invalid combinations', () => { - const unknownCol = () => { - const str = strCol('foo'); - return { ...str, operation: { ...str.operation, dataType: 'wonkies' as DataType } }; - }; - + test('partially maps invalid combinations, but hides them', () => { expect( ([ { @@ -111,19 +101,41 @@ describe('xy_suggestions', () => { }, { isMultiRow: false, - columns: [strCol('foo'), numCol('bar')], + columns: [numCol('bar')], layerId: 'first', changeType: 'unchanged', }, + ] as TableSuggestion[]).map((table) => { + const suggestions = getSuggestions({ table, keptLayerIds: [] }); + expect(suggestions.every((suggestion) => suggestion.hide)).toEqual(true); + expect(suggestions).toHaveLength(10); + }) + ); + }); + + test('rejects incomplete configurations if there is a state already but no sub visualization id', () => { + expect( + ([ { isMultiRow: true, - columns: [unknownCol(), numCol('bar')], + columns: [dateCol('a')], layerId: 'first', - changeType: 'unchanged', + changeType: 'reduced', + }, + { + isMultiRow: false, + columns: [numCol('bar')], + layerId: 'first', + changeType: 'reduced', }, - ] as TableSuggestion[]).map((table) => - expect(getSuggestions({ table, keptLayerIds: [] })).toEqual([]) - ) + ] as TableSuggestion[]).map((table) => { + const suggestions = getSuggestions({ + table, + keptLayerIds: [], + state: {} as XYState, + }); + expect(suggestions).toHaveLength(0); + }) ); }); @@ -915,8 +927,9 @@ describe('xy_suggestions', () => { Object { "seriesType": "bar_stacked", "splitAccessor": undefined, - "x": "quantity", + "x": undefined, "y": Array [ + "quantity", "price", ], }, diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 7bbb0395773062..a308a0c2930298 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -39,33 +39,34 @@ export function getSuggestions({ subVisualizationId, mainPalette, }: SuggestionRequest): Array> { - if ( - // We only render line charts for multi-row queries. We require at least - // two columns: one for x and at least one for y, and y columns must be numeric. - // We reject any datasource suggestions which have a column of an unknown type. + const incompleteTable = !table.isMultiRow || table.columns.length <= 1 || table.columns.every((col) => col.operation.dataType !== 'number') || - table.columns.some((col) => !columnSortOrder.hasOwnProperty(col.operation.dataType)) - ) { - if (table.changeType === 'unchanged' && state) { - // this isn't a table we would switch to, but we have a state already. In this case, just use the current state for all series types - return visualizationTypes.map((visType) => { - const seriesType = visType.id as SeriesType; - return { - seriesType, - score: 0, - state: { - ...state, - preferredSeriesType: seriesType, - layers: state.layers.map((layer) => ({ ...layer, seriesType })), - }, - previewIcon: getIconForSeries(seriesType), - title: visType.label, - hide: true, - }; - }); - } + table.columns.some((col) => !columnSortOrder.hasOwnProperty(col.operation.dataType)); + if (incompleteTable && table.changeType === 'unchanged' && state) { + // this isn't a table we would switch to, but we have a state already. In this case, just use the current state for all series types + return visualizationTypes.map((visType) => { + const seriesType = visType.id as SeriesType; + return { + seriesType, + score: 0, + state: { + ...state, + preferredSeriesType: seriesType, + layers: state.layers.map((layer) => ({ ...layer, seriesType })), + }, + previewIcon: getIconForSeries(seriesType), + title: visType.label, + hide: true, + }; + }); + } + + if (incompleteTable && state && !subVisualizationId) { + // reject incomplete configurations if the sub visualization isn't specifically requested + // this allows to switch chart types via switcher with incomplete configurations, but won't + // cause incomplete suggestions getting auto applied on dropped fields return []; } @@ -108,13 +109,16 @@ function getSuggestionForColumns( mainPalette, }); } else if (buckets.length === 0) { - const [x, ...yValues] = prioritizeColumns(values); + const [yValues, [xValue, splitBy]] = partition( + prioritizeColumns(values), + (col) => col.operation.dataType === 'number' && !col.operation.isBucketed + ); return getSuggestionsForLayer({ layerId: table.layerId, changeType: table.changeType, - xValue: x, + xValue, yValues, - splitBy: undefined, + splitBy, currentState, tableLabel: table.label, keptLayerIds, @@ -241,9 +245,13 @@ function getSuggestionsForLayer({ return visualizationTypes .map((visType) => { return { - ...buildSuggestion({ ...options, seriesType: visType.id as SeriesType }), + ...buildSuggestion({ + ...options, + seriesType: visType.id as SeriesType, + // explicitly hide everything besides stacked bars, use default hiding logic for stacked bars + hide: visType.id === 'bar_stacked' ? undefined : true, + }), title: visType.label, - hide: visType.id !== 'bar_stacked', }; }) .sort((a, b) => (a.state.preferredSeriesType === 'bar_stacked' ? -1 : 1)); @@ -541,7 +549,11 @@ function buildSuggestion({ // Only advertise very clear changes when XY chart is not active ((!currentState && changeType !== 'unchanged' && changeType !== 'extended') || // Don't advertise removing dimensions - (currentState && changeType === 'reduced')), + (currentState && changeType === 'reduced') || + // Don't advertise charts without y axis + yValues.length === 0 || + // Don't advertise charts without at least one split + (!xValue && !splitBy)), state, previewIcon: getIconForSeries(seriesType), }; diff --git a/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts b/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts index af3ec42ab4ec5d..b21a821ec6a723 100644 --- a/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts +++ b/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts @@ -46,7 +46,7 @@ describe('createOnPreResponseHandler', () => { const license$ = new BehaviorSubject(licenseMock.createLicense({ signature: 'foo' })); const refresh = jest.fn().mockImplementation( () => - new Promise((resolve) => { + new Promise((resolve) => { setTimeout(() => { license$.next(updatedLicense); resolve(); diff --git a/x-pack/plugins/lists/public/common/hooks/use_async.test.ts b/x-pack/plugins/lists/public/common/hooks/use_async.test.ts index 33f28cfc97291c..6ae7aa25fc3bf2 100644 --- a/x-pack/plugins/lists/public/common/hooks/use_async.test.ts +++ b/x-pack/plugins/lists/public/common/hooks/use_async.test.ts @@ -80,7 +80,7 @@ describe('useAsync', () => { it('populates the loading state while the function is pending', async () => { let resolve: () => void; - fn.mockImplementation(() => new Promise((_resolve) => (resolve = _resolve))); + fn.mockImplementation(() => new Promise((_resolve) => (resolve = _resolve))); const { result, waitForNextUpdate } = renderHook(() => useAsync(fn)); diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.d.ts b/x-pack/plugins/maps/public/classes/joins/inner_join.d.ts deleted file mode 100644 index 987e7bc93c2f6b..00000000000000 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.d.ts +++ /dev/null @@ -1,44 +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 { Feature, GeoJsonProperties } from 'geojson'; -import { ESTermSource } from '../sources/es_term_source'; -import { IJoin } from './join'; -import { JoinDescriptor } from '../../../common/descriptor_types'; -import { ISource } from '../sources/source'; -import { ITooltipProperty } from '../tooltips/tooltip_property'; -import { IField } from '../fields/field'; -import { PropertiesMap } from '../../../common/elasticsearch_util'; - -export class InnerJoin implements IJoin { - constructor(joinDescriptor: JoinDescriptor, leftSource: ISource); - - destroy: () => void; - - getRightJoinSource(): ESTermSource; - - toDescriptor(): JoinDescriptor; - - getJoinFields: () => IField[]; - - getLeftField: () => IField; - - getIndexPatternIds: () => string[]; - - getQueryableIndexPatternIds: () => string[]; - - getSourceDataRequestId: () => string; - - getSourceMetaDataRequestId(): string; - - getSourceFormattersDataRequestId(): string; - - getTooltipProperties(properties: GeoJsonProperties): Promise; - - hasCompleteConfig: () => boolean; - - joinPropertiesToFeature: (feature: Feature, propertiesMap?: PropertiesMap) => boolean; -} diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.js b/x-pack/plugins/maps/public/classes/joins/inner_join.ts similarity index 54% rename from x-pack/plugins/maps/public/classes/joins/inner_join.js rename to x-pack/plugins/maps/public/classes/joins/inner_join.ts index 75bf59d9d64041..32bd767aa94d8f 100644 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.js +++ b/x-pack/plugins/maps/public/classes/joins/inner_join.ts @@ -4,44 +4,58 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Query } from 'src/plugins/data/public'; +import { Feature, GeoJsonProperties } from 'geojson'; import { ESTermSource } from '../sources/es_term_source'; import { getComputedFieldNamePrefix } from '../styles/vector/style_util'; import { META_DATA_REQUEST_ID_SUFFIX, FORMATTERS_DATA_REQUEST_ID_SUFFIX, } from '../../../common/constants'; +import { JoinDescriptor } from '../../../common/descriptor_types'; +import { IVectorSource } from '../sources/vector_source'; +import { IField } from '../fields/field'; +import { PropertiesMap } from '../../../common/elasticsearch_util'; export class InnerJoin { - constructor(joinDescriptor, leftSource) { + private readonly _descriptor: JoinDescriptor; + private readonly _rightSource?: ESTermSource; + private readonly _leftField?: IField; + + constructor(joinDescriptor: JoinDescriptor, leftSource: IVectorSource) { this._descriptor = joinDescriptor; const inspectorAdapters = leftSource.getInspectorAdapters(); - this._rightSource = new ESTermSource(joinDescriptor.right, inspectorAdapters); - this._leftField = this._descriptor.leftField + if ( + joinDescriptor.right && + 'indexPatternId' in joinDescriptor.right && + 'term' in joinDescriptor.right + ) { + this._rightSource = new ESTermSource(joinDescriptor.right, inspectorAdapters); + } + this._leftField = joinDescriptor.leftField ? leftSource.createField({ fieldName: joinDescriptor.leftField }) - : null; + : undefined; } destroy() { - this._rightSource.destroy(); + if (this._rightSource) { + this._rightSource.destroy(); + } } hasCompleteConfig() { - if (this._leftField && this._rightSource) { - return this._rightSource.hasCompleteConfig(); - } - - return false; + return this._leftField && this._rightSource ? this._rightSource.hasCompleteConfig() : false; } getJoinFields() { - return this._rightSource.getMetricFields(); + return this._rightSource ? this._rightSource.getMetricFields() : []; } // Source request id must be static and unique because the re-fetch logic uses the id to locate the previous request. // Elasticsearch sources have a static and unique id so that requests can be modified in the inspector. // Using the right source id as the source request id because it meets the above criteria. getSourceDataRequestId() { - return `join_source_${this._rightSource.getId()}`; + return `join_source_${this._rightSource!.getId()}`; } getSourceMetaDataRequestId() { @@ -52,11 +66,17 @@ export class InnerJoin { return `${this.getSourceDataRequestId()}_${FORMATTERS_DATA_REQUEST_ID_SUFFIX}`; } - getLeftField() { + getLeftField(): IField { + if (!this._leftField) { + throw new Error('Cannot get leftField from InnerJoin with incomplete config'); + } return this._leftField; } - joinPropertiesToFeature(feature, propertiesMap) { + joinPropertiesToFeature(feature: Feature, propertiesMap: PropertiesMap): boolean { + if (!feature.properties || !this._leftField || !this._rightSource) { + return false; + } const rightMetricFields = this._rightSource.getMetricFields(); // delete feature properties added by previous join for (let j = 0; j < rightMetricFields.length; j++) { @@ -70,7 +90,7 @@ export class InnerJoin { featurePropertyKey.length >= stylePropertyPrefix.length && featurePropertyKey.substring(0, stylePropertyPrefix.length) === stylePropertyPrefix ) { - delete feature.properties[featurePropertyKey]; + delete feature.properties![featurePropertyKey]; } }); } @@ -78,7 +98,7 @@ export class InnerJoin { const joinKey = feature.properties[this._leftField.getName()]; const coercedKey = typeof joinKey === 'undefined' || joinKey === null ? null : joinKey.toString(); - if (propertiesMap && coercedKey !== null && propertiesMap.has(coercedKey)) { + if (coercedKey !== null && propertiesMap.has(coercedKey)) { Object.assign(feature.properties, propertiesMap.get(coercedKey)); return true; } else { @@ -86,27 +106,30 @@ export class InnerJoin { } } - getRightJoinSource() { + getRightJoinSource(): ESTermSource { + if (!this._rightSource) { + throw new Error('Cannot get rightSource from InnerJoin with incomplete config'); + } return this._rightSource; } - toDescriptor() { + toDescriptor(): JoinDescriptor { return this._descriptor; } - async getTooltipProperties(properties) { - return await this._rightSource.getTooltipProperties(properties); + async getTooltipProperties(properties: GeoJsonProperties) { + return await this.getRightJoinSource().getTooltipProperties(properties); } getIndexPatternIds() { - return this._rightSource.getIndexPatternIds(); + return this.getRightJoinSource().getIndexPatternIds(); } getQueryableIndexPatternIds() { - return this._rightSource.getQueryableIndexPatternIds(); + return this.getRightJoinSource().getQueryableIndexPatternIds(); } - getWhereQuery() { - return this._rightSource.getWhereQuery(); + getWhereQuery(): Query | undefined { + return this.getRightJoinSource().getWhereQuery(); } } diff --git a/x-pack/plugins/maps/public/classes/joins/join.ts b/x-pack/plugins/maps/public/classes/joins/join.ts deleted file mode 100644 index 465ffbda273037..00000000000000 --- a/x-pack/plugins/maps/public/classes/joins/join.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Feature, GeoJsonProperties } from 'geojson'; -import { ESTermSource } from '../sources/es_term_source'; -import { JoinDescriptor } from '../../../common/descriptor_types'; -import { ITooltipProperty } from '../tooltips/tooltip_property'; -import { IField } from '../fields/field'; -import { PropertiesMap } from '../../../common/elasticsearch_util'; - -export interface IJoin { - destroy: () => void; - - getRightJoinSource: () => ESTermSource; - - toDescriptor: () => JoinDescriptor; - - getJoinFields: () => IField[]; - - getLeftField: () => IField; - - getIndexPatternIds: () => string[]; - - getQueryableIndexPatternIds: () => string[]; - - getSourceDataRequestId: () => string; - - getSourceMetaDataRequestId: () => string; - - getSourceFormattersDataRequestId: () => string; - - getTooltipProperties: (properties: GeoJsonProperties) => Promise; - - hasCompleteConfig: () => boolean; - - joinPropertiesToFeature: (feature: Feature, propertiesMap?: PropertiesMap) => boolean; -} diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index b4c0098bb13389..e4ae0aed15729b 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -53,7 +53,7 @@ import { } from '../../../../common/descriptor_types'; import { IVectorSource } from '../../sources/vector_source'; import { CustomIconAndTooltipContent, ILayer } from '../layer'; -import { IJoin } from '../../joins/join'; +import { InnerJoin } from '../../joins/inner_join'; import { IField } from '../../fields/field'; import { DataRequestContext } from '../../../actions'; import { ITooltipProperty } from '../../tooltips/tooltip_property'; @@ -68,21 +68,21 @@ interface SourceResult { interface JoinState { dataHasChanged: boolean; - join: IJoin; + join: InnerJoin; propertiesMap?: PropertiesMap; } export interface VectorLayerArguments { source: IVectorSource; - joins?: IJoin[]; + joins?: InnerJoin[]; layerDescriptor: VectorLayerDescriptor; } export interface IVectorLayer extends ILayer { getFields(): Promise; getStyleEditorFields(): Promise; - getJoins(): IJoin[]; - getValidJoins(): IJoin[]; + getJoins(): InnerJoin[]; + getValidJoins(): InnerJoin[]; getSource(): IVectorSource; getFeatureById(id: string | number): Feature | null; getPropertiesForTooltip(properties: GeoJsonProperties): Promise; @@ -93,7 +93,7 @@ export class VectorLayer extends AbstractLayer { static type = LAYER_TYPE.VECTOR; protected readonly _style: IVectorStyle; - private readonly _joins: IJoin[]; + private readonly _joins: InnerJoin[]; static createDescriptor( options: Partial, @@ -339,7 +339,7 @@ export class VectorLayer extends AbstractLayer { onLoadError, registerCancelCallback, dataFilters, - }: { join: IJoin } & DataRequestContext): Promise { + }: { join: InnerJoin } & DataRequestContext): Promise { const joinSource = join.getRightJoinSource(); const sourceDataId = join.getSourceDataRequestId(); const requestToken = Symbol(`layer-join-refresh:${this.getId()} - ${sourceDataId}`); @@ -453,10 +453,9 @@ export class VectorLayer extends AbstractLayer { for (let j = 0; j < joinStates.length; j++) { const joinState = joinStates[j]; const innerJoin = joinState.join; - const canJoinOnCurrent = innerJoin.joinPropertiesToFeature( - feature, - joinState.propertiesMap - ); + const canJoinOnCurrent = joinState.propertiesMap + ? innerJoin.joinPropertiesToFeature(feature, joinState.propertiesMap) + : false; isFeatureVisible = isFeatureVisible && canJoinOnCurrent; } @@ -559,7 +558,7 @@ export class VectorLayer extends AbstractLayer { }); } - async _syncJoinStyleMeta(syncContext: DataRequestContext, join: IJoin, style: IVectorStyle) { + async _syncJoinStyleMeta(syncContext: DataRequestContext, join: InnerJoin, style: IVectorStyle) { const joinSource = join.getRightJoinSource(); return this._syncStyleMeta({ source: joinSource, @@ -663,7 +662,7 @@ export class VectorLayer extends AbstractLayer { }); } - async _syncJoinFormatters(syncContext: DataRequestContext, join: IJoin, style: IVectorStyle) { + async _syncJoinFormatters(syncContext: DataRequestContext, join: InnerJoin, style: IVectorStyle) { const joinSource = join.getRightJoinSource(); return this._syncFormatters({ source: joinSource, diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts index 8ef50a1cb7a1c9..328594f00a1f01 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts @@ -56,6 +56,9 @@ export class ESTermSource extends AbstractESAggSource { } return { ...normalizedDescriptor, + indexPatternTitle: descriptor.indexPatternTitle + ? descriptor.indexPatternTitle + : descriptor.indexPatternId, term: descriptor.term!, type: SOURCE_TYPES.ES_TERM_SOURCE, }; @@ -64,7 +67,7 @@ export class ESTermSource extends AbstractESAggSource { private readonly _termField: ESDocField; readonly _descriptor: ESTermSourceDescriptor; - constructor(descriptor: ESTermSourceDescriptor, inspectorAdapters: Adapters) { + constructor(descriptor: ESTermSourceDescriptor, inspectorAdapters?: Adapters) { const sourceDescriptor = ESTermSource.createDescriptor(descriptor); super(sourceDescriptor, inspectorAdapters); this._descriptor = sourceDescriptor; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx index 98b58def905eb7..c2cd46f26f990c 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx @@ -29,7 +29,7 @@ import { } from '../../../../../common/descriptor_types'; import { IField } from '../../../fields/field'; import { IVectorLayer } from '../../../layers/vector_layer/vector_layer'; -import { IJoin } from '../../../joins/join'; +import { InnerJoin } from '../../../joins/inner_join'; import { IVectorStyle } from '../vector_style'; import { getComputedFieldName } from '../style_util'; @@ -88,7 +88,7 @@ export class DynamicStyleProperty return SOURCE_META_DATA_REQUEST_ID; } - const join = this._layer.getValidJoins().find((validJoin: IJoin) => { + const join = this._layer.getValidJoins().find((validJoin: InnerJoin) => { return validJoin.getRightJoinSource().hasMatchingMetricField(fieldName); }); return join ? join.getSourceMetaDataRequestId() : null; diff --git a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property.ts b/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property.ts index efdede82a74499..5c45b33a7c31b5 100644 --- a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property.ts +++ b/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property.ts @@ -5,14 +5,14 @@ */ import { ITooltipProperty } from './tooltip_property'; -import { IJoin } from '../joins/join'; +import { InnerJoin } from '../joins/inner_join'; import { Filter } from '../../../../../../src/plugins/data/public'; export class JoinTooltipProperty implements ITooltipProperty { private readonly _tooltipProperty: ITooltipProperty; - private readonly _leftInnerJoins: IJoin[]; + private readonly _leftInnerJoins: InnerJoin[]; - constructor(tooltipProperty: ITooltipProperty, leftInnerJoins: IJoin[]) { + constructor(tooltipProperty: ITooltipProperty, leftInnerJoins: InnerJoin[]) { this._tooltipProperty = tooltipProperty; this._leftInnerJoins = leftInnerJoins; } diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index f6282be26b40c2..9a1b31852d39c7 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -20,7 +20,6 @@ import { getTimeFilter } from '../kibana_services'; import { getInspectorAdapters } from '../reducers/non_serializable_instances'; import { TiledVectorLayer } from '../classes/layers/tiled_vector_layer/tiled_vector_layer'; import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from '../reducers/util'; -import { IJoin } from '../classes/joins/join'; import { InnerJoin } from '../classes/joins/inner_join'; import { getSourceByType } from '../classes/sources/source_registry'; import { GeojsonFileSource } from '../classes/sources/geojson_file_source'; @@ -63,11 +62,11 @@ export function createLayerInstance( case TileLayer.type: return new TileLayer({ layerDescriptor, source: source as ITMSSource }); case VectorLayer.type: - const joins: IJoin[] = []; + const joins: InnerJoin[] = []; const vectorLayerDescriptor = layerDescriptor as VectorLayerDescriptor; if (vectorLayerDescriptor.joins) { vectorLayerDescriptor.joins.forEach((joinDescriptor) => { - const join = new InnerJoin(joinDescriptor, source); + const join = new InnerJoin(joinDescriptor, source as IVectorSource); joins.push(join); }); } @@ -357,7 +356,7 @@ export const getSelectedLayerJoinDescriptors = createSelector(getSelectedLayer, return []; } - return (selectedLayer as IVectorLayer).getJoins().map((join: IJoin) => { + return (selectedLayer as IVectorLayer).getJoins().map((join: InnerJoin) => { return join.toDescriptor(); }); }); diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index d708cd56b78dff..91020eee266022 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -123,7 +123,7 @@ export function getPluginPrivileges() { catalogue: [], savedObject: { all: [], - read: ['ml-job'], + read: [ML_SAVED_OBJECT_TYPE], }, api: apmUserMlCapabilitiesKeys.map((k) => `ml:${k}`), ui: apmUserMlCapabilitiesKeys, diff --git a/x-pack/plugins/ml/common/types/ml_url_generator.ts b/x-pack/plugins/ml/common/types/ml_url_generator.ts index b5a78ee746efe2..1232c94d7dee11 100644 --- a/x-pack/plugins/ml/common/types/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/types/ml_url_generator.ts @@ -66,7 +66,7 @@ export type AnomalyDetectionUrlState = MLPageState< >; export interface ExplorerAppState { mlExplorerSwimlane: { - selectedType?: string; + selectedType?: 'overall' | 'viewBy'; selectedLanes?: string[]; selectedTimes?: number[]; showTopFieldValues?: boolean; @@ -81,6 +81,7 @@ export interface ExplorerAppState { queryString?: string; }; query?: any; + mlShowCharts?: boolean; } export interface ExplorerGlobalState { ml: { jobIds: JobId[] }; @@ -124,21 +125,21 @@ export interface TimeSeriesExplorerGlobalState { } export interface TimeSeriesExplorerAppState { - zoom?: { - from?: string; - to?: string; - }; mlTimeSeriesExplorer?: { forecastId?: string; detectorIndex?: number; entities?: Record; + zoom?: { + from?: string; + to?: string; + }; functionDescription?: string; }; query?: any; } export interface TimeSeriesExplorerPageState - extends Pick, + extends Pick, Pick { jobIds?: JobId[]; timeRange?: TimeRange; diff --git a/x-pack/plugins/ml/common/types/saved_objects.ts b/x-pack/plugins/ml/common/types/saved_objects.ts index 9f4d402ec1759b..d6c9ad758e8c66 100644 --- a/x-pack/plugins/ml/common/types/saved_objects.ts +++ b/x-pack/plugins/ml/common/types/saved_objects.ts @@ -27,3 +27,12 @@ export interface InitializeSavedObjectResponse { success: boolean; error?: any; } + +export interface DeleteJobCheckResponse { + [jobId: string]: DeleteJobPermission; +} + +export interface DeleteJobPermission { + canDelete: boolean; + canUntag: boolean; +} diff --git a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx index 70538d4dc3a917..d0a3bd06526905 100644 --- a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx @@ -4,41 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ -/* - * React component for a checkbox element to toggle charts display. - */ -import React, { FC } from 'react'; - -import { EuiCheckbox } from '@elastic/eui'; -// @ts-ignore -import makeId from '@elastic/eui/lib/components/form/form_row/make_id'; - +import React, { FC, useCallback, useMemo } from 'react'; +import { EuiCheckbox, htmlIdGenerator } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { useUrlState } from '../../../util/url_state'; +import { useExplorerUrlState } from '../../../explorer/hooks/use_explorer_url_state'; const SHOW_CHARTS_DEFAULT = true; -const SHOW_CHARTS_APP_STATE_NAME = 'mlShowCharts'; -export const useShowCharts = () => { - const [appState, setAppState] = useUrlState('_a'); +export const useShowCharts = (): [boolean, (v: boolean) => void] => { + const [explorerUrlState, setExplorerUrlState] = useExplorerUrlState(); + + const showCharts = explorerUrlState?.mlShowCharts ?? SHOW_CHARTS_DEFAULT; - return [ - appState?.mlShowCharts !== undefined ? appState?.mlShowCharts : SHOW_CHARTS_DEFAULT, - (d: boolean) => setAppState(SHOW_CHARTS_APP_STATE_NAME, d), - ]; + const setShowCharts = useCallback( + (v: boolean) => { + setExplorerUrlState({ mlShowCharts: v }); + }, + [setExplorerUrlState] + ); + + return [showCharts, setShowCharts]; }; +/* + * React component for a checkbox element to toggle charts display. + */ export const CheckboxShowCharts: FC = () => { - const [showCharts, setShowCarts] = useShowCharts(); + const [showCharts, setShowCharts] = useShowCharts(); const onChange = (e: React.ChangeEvent) => { - setShowCarts(e.target.checked); + setShowCharts(e.target.checked); }; + const id = useMemo(() => htmlIdGenerator()(), []); + return ( { - const [appState, setAppState] = useUrlState('_a'); - return [ - (appState && appState[TABLE_INTERVAL_APP_STATE_NAME]) || TABLE_INTERVAL_DEFAULT, - (d: TableInterval) => setAppState(TABLE_INTERVAL_APP_STATE_NAME, d), - ]; +export const useTableInterval = (): [TableInterval, (v: TableInterval) => void] => { + return usePageUrlState('mlSelectInterval', TABLE_INTERVAL_DEFAULT); }; +/* + * React component for rendering a select element with various aggregation interval levels. + */ export const SelectInterval: FC = () => { const [interval, setInterval] = useTableInterval(); diff --git a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx index b8333e72c9ffbe..3e48dcba84be2d 100644 --- a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx @@ -14,7 +14,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; import { getSeverityColor } from '../../../../../common/util/anomaly_utils'; -import { useUrlState } from '../../../util/url_state'; +import { usePageUrlState } from '../../../util/url_state'; const warningLabel = i18n.translate('xpack.ml.controls.selectSeverity.warningLabel', { defaultMessage: 'warning', @@ -78,15 +78,9 @@ function optionValueToThreshold(value: number) { } const TABLE_SEVERITY_DEFAULT = SEVERITY_OPTIONS[0]; -const TABLE_SEVERITY_APP_STATE_NAME = 'mlSelectSeverity'; -export const useTableSeverity = () => { - const [appState, setAppState] = useUrlState('_a'); - - return [ - (appState && appState[TABLE_SEVERITY_APP_STATE_NAME]) || TABLE_SEVERITY_DEFAULT, - (d: TableSeverity) => setAppState(TABLE_SEVERITY_APP_STATE_NAME, d), - ]; +export const useTableSeverity = (): [TableSeverity, (v: TableSeverity) => void] => { + return usePageUrlState('mlSelectSeverity', TABLE_SEVERITY_DEFAULT); }; const getSeverityOptions = () => diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx index 7d1a616d57114b..a4dc78ea53a77e 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useState, useEffect, useCallback } from 'react'; +import React, { FC, Fragment, useCallback, useEffect, useState } from 'react'; import { Subscription } from 'rxjs'; +import { debounce } from 'lodash'; + import { EuiSuperDatePicker, OnRefreshProps } from '@elastic/eui'; -import { TimeRange, TimeHistoryContract } from 'src/plugins/data/public'; +import { TimeHistoryContract, TimeRange } from 'src/plugins/data/public'; import { mlTimefilterRefresh$ } from '../../../services/timefilter_refresh_service'; import { useUrlState } from '../../../util/url_state'; @@ -52,9 +54,9 @@ export const DatePickerWrapper: FC = () => { globalState?.refreshInterval ?? timefilter.getRefreshInterval(); const setRefreshInterval = useCallback( - (refreshIntervalUpdate: RefreshInterval) => { - setGlobalState('refreshInterval', refreshIntervalUpdate); - }, + debounce((refreshIntervalUpdate: RefreshInterval) => { + setGlobalState('refreshInterval', refreshIntervalUpdate, true); + }, 200), [setGlobalState] ); diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts b/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts index 96d41be03a142e..6e9ac4d0a1e1c1 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useMemo } from 'react'; +import { useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { PLUGIN_ID } from '../../../../common/constants/app'; @@ -21,8 +21,8 @@ export const useNavigateToPath = () => { const location = useLocation(); - return useMemo(() => { - return (path: string | undefined, preserveSearch = false) => { + return useCallback( + async (path: string | undefined, preserveSearch = false) => { if (path === undefined) return; const modifiedPath = `${path}${preserveSearch === true ? location.search : ''}`; /** @@ -33,7 +33,8 @@ export const useNavigateToPath = () => { : getUrlForApp(PLUGIN_ID, { path: modifiedPath, }); - navigateToUrl(url); - }; - }, [location]); + await navigateToUrl(url); + }, + [location] + ); }; diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 77826d60b2394e..5712f3c4843b48 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -285,6 +285,7 @@ export const useExplorerData = (): [Partial | undefined, (d: any) uiSettings, }, } = useMlKibana(); + const loadExplorerData = useMemo(() => { const service = new AnomalyTimelineService( timefilter, @@ -293,6 +294,7 @@ export const useExplorerData = (): [Partial | undefined, (d: any) ); return loadExplorerDataProvider(service); }, []); + const loadExplorerData$ = useMemo(() => new Subject(), []); const explorerData$ = useMemo(() => loadExplorerData$.pipe(switchMap(loadExplorerData)), []); const explorerData = useObservable(explorerData$); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index e0ed2ea6cf5e08..8a95e5c6adbd68 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts @@ -95,7 +95,9 @@ const setExplorerDataActionCreator = (payload: DeepPartial) => ({ type: EXPLORER_ACTION.SET_EXPLORER_DATA, payload, }); -const setFilterDataActionCreator = (payload: DeepPartial) => ({ +const setFilterDataActionCreator = ( + payload: Partial> +) => ({ type: EXPLORER_ACTION.SET_FILTER_DATA, payload, }); @@ -134,7 +136,7 @@ export const explorerService = { setExplorerData: (payload: DeepPartial) => { explorerAction$.next(setExplorerDataActionCreator(payload)); }, - setFilterData: (payload: DeepPartial) => { + setFilterData: (payload: Partial>) => { explorerAction$.next(setFilterDataActionCreator(payload)); }, setSwimlaneContainerWidth: (payload: number) => { diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts new file mode 100644 index 00000000000000..d51be619c39eea --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { usePageUrlState } from '../../util/url_state'; +import { ExplorerAppState } from '../../../../common/types/ml_url_generator'; +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; + +export function useExplorerUrlState() { + return usePageUrlState(ML_PAGES.ANOMALY_EXPLORER); +} diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts index c7cda2372bcebe..7602954b4c8c38 100644 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts @@ -5,21 +5,21 @@ */ import { useCallback, useMemo } from 'react'; -import { useUrlState } from '../../util/url_state'; import { SWIMLANE_TYPE } from '../explorer_constants'; import { AppStateSelectedCells } from '../explorer_utils'; +import { ExplorerAppState } from '../../../../common/types/ml_url_generator'; export const useSelectedCells = ( - appState: any, - setAppState: ReturnType[1] + appState: ExplorerAppState, + setAppState: (update: Partial) => void ): [AppStateSelectedCells | undefined, (swimlaneSelectedCells: AppStateSelectedCells) => void] => { // keep swimlane selection, restore selectedCells from AppState const selectedCells = useMemo(() => { return appState?.mlExplorerSwimlane?.selectedType !== undefined ? { type: appState.mlExplorerSwimlane.selectedType, - lanes: appState.mlExplorerSwimlane.selectedLanes, - times: appState.mlExplorerSwimlane.selectedTimes, + lanes: appState.mlExplorerSwimlane.selectedLanes!, + times: appState.mlExplorerSwimlane.selectedTimes!, showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues, viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName, } @@ -29,7 +29,9 @@ export const useSelectedCells = ( const setSelectedCells = useCallback( (swimlaneSelectedCells: AppStateSelectedCells) => { - const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane }; + const mlExplorerSwimlane = { + ...appState.mlExplorerSwimlane, + } as ExplorerAppState['mlExplorerSwimlane']; if (swimlaneSelectedCells !== undefined) { swimlaneSelectedCells.showTopFieldValues = false; @@ -51,13 +53,13 @@ export const useSelectedCells = ( mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; - setAppState('mlExplorerSwimlane', mlExplorerSwimlane); + setAppState({ mlExplorerSwimlane }); } else { delete mlExplorerSwimlane.selectedType; delete mlExplorerSwimlane.selectedLanes; delete mlExplorerSwimlane.selectedTimes; delete mlExplorerSwimlane.showTopFieldValues; - setAppState('mlExplorerSwimlane', mlExplorerSwimlane); + setAppState({ mlExplorerSwimlane }); } }, [appState?.mlExplorerSwimlane, selectedCells, setAppState] diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index 6e3b9031de653c..3b980ce52fa6dc 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -68,6 +68,12 @@ export class JobsListView extends Component { // used to block timeouts for results polling // which can run after unmounting this._isMounted = false; + /** + * Indicates if the filters has been initialized by {@link JobFilterBar} component + * @type {boolean} + * @private + */ + this._isFiltersSet = false; } componentDidMount() { @@ -227,9 +233,15 @@ export class JobsListView extends Component { const filterClauses = (query && query.ast && query.ast.clauses) || []; const filteredJobsSummaryList = filterJobs(this.state.jobsSummaryList, filterClauses); - this.props.onJobsViewStateUpdate({ - queryText: query?.text, - }); + this.props.onJobsViewStateUpdate( + { + queryText: query?.text, + }, + // Replace the URL state on filters initialization + this._isFiltersSet === false + ); + + this._isFiltersSet = true; this.setState({ filteredJobsSummaryList, filterClauses }, () => { this.refreshSelectedJobs(); diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index b91a5bd4a1aa48..83f876bcf7b56c 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -22,7 +22,6 @@ import { useSelectedCells } from '../../explorer/hooks/use_selected_cells'; import { mlJobService } from '../../services/job_service'; import { ml } from '../../services/ml_api_service'; import { useExplorerData } from '../../explorer/actions'; -import { ExplorerAppState } from '../../../../common/types/ml_url_generator'; import { explorerService } from '../../explorer/explorer_dashboard_service'; import { getDateFormatTz } from '../../explorer/explorer_utils'; import { useJobSelection } from '../../components/job_selector/use_job_selection'; @@ -36,6 +35,7 @@ import { isViewBySwimLaneData } from '../../explorer/swimlane_container'; import { JOB_ID } from '../../../../common/constants/anomalies'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; import { AnnotationUpdatesService } from '../../services/annotations_service'; +import { useExplorerUrlState } from '../../explorer/hooks/use_explorer_url_state'; export const explorerRouteFactory = ( navigateToPath: NavigateToPath, @@ -77,7 +77,8 @@ interface ExplorerUrlStateManagerProps { } const ExplorerUrlStateManager: FC = ({ jobsWithTimeRange }) => { - const [appState, setAppState] = useUrlState('_a'); + const [explorerUrlState, setExplorerUrlState] = useExplorerUrlState(); + const [globalState, setGlobalState] = useUrlState('_g'); const [lastRefresh, setLastRefresh] = useState(0); const [stoppedPartitions, setStoppedPartitions] = useState(); @@ -86,6 +87,9 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const { jobIds } = useJobSelection(jobsWithTimeRange); + const explorerAppState = useObservable(explorerService.appState$); + const explorerState = useObservable(explorerService.state$); + const refresh = useRefresh(); useEffect(() => { @@ -155,73 +159,76 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim } }, [JSON.stringify(jobIds)]); + /** + * TODO get rid of the intermediate state in explorerService. + * URL state should be the only source of truth for related props. + */ useEffect(() => { - const viewByFieldName = appState?.mlExplorerSwimlane?.viewByFieldName; - if (viewByFieldName !== undefined) { - explorerService.setViewBySwimlaneFieldName(viewByFieldName); - } - - const filterData = appState?.mlExplorerFilter; + const filterData = explorerUrlState?.mlExplorerFilter; if (filterData !== undefined) { explorerService.setFilterData(filterData); } - const viewByPerPage = (appState as ExplorerAppState)?.mlExplorerSwimlane?.viewByPerPage; - if (viewByPerPage) { + const { viewByFieldName, viewByFromPage, viewByPerPage } = + explorerUrlState?.mlExplorerSwimlane ?? {}; + + if (viewByFieldName !== undefined) { + explorerService.setViewBySwimlaneFieldName(viewByFieldName); + } + + if (viewByPerPage !== undefined) { explorerService.setViewByPerPage(viewByPerPage); } - const viewByFromPage = (appState as ExplorerAppState)?.mlExplorerSwimlane?.viewByFromPage; - if (viewByFromPage) { + if (viewByFromPage !== undefined) { explorerService.setViewByFromPage(viewByFromPage); } }, []); + /** Sync URL state with {@link explorerService} state */ + useEffect(() => { + const replaceState = explorerUrlState?.mlExplorerSwimlane?.viewByFieldName === undefined; + if (explorerAppState?.mlExplorerSwimlane?.viewByFieldName !== undefined) { + setExplorerUrlState(explorerAppState, replaceState); + } + }, [explorerAppState]); + const [explorerData, loadExplorerData] = useExplorerData(); + useEffect(() => { if (explorerData !== undefined && Object.keys(explorerData).length > 0) { explorerService.setExplorerData(explorerData); } }, [explorerData]); - const explorerAppState = useObservable(explorerService.appState$); - useEffect(() => { - if ( - explorerAppState !== undefined && - explorerAppState.mlExplorerSwimlane.viewByFieldName !== undefined - ) { - setAppState(explorerAppState); - } - }, [explorerAppState]); - - const explorerState = useObservable(explorerService.state$); const [showCharts] = useShowCharts(); const [tableInterval] = useTableInterval(); const [tableSeverity] = useTableSeverity(); - const [selectedCells, setSelectedCells] = useSelectedCells(appState, setAppState); + const [selectedCells, setSelectedCells] = useSelectedCells(explorerUrlState, setExplorerUrlState); useEffect(() => { explorerService.setSelectedCells(selectedCells); }, [JSON.stringify(selectedCells)]); const loadExplorerDataConfig = - (explorerState !== undefined && { - bounds: explorerState.bounds, - lastRefresh, - influencersFilterQuery: explorerState.influencersFilterQuery, - noInfluencersConfigured: explorerState.noInfluencersConfigured, - selectedCells, - selectedJobs: explorerState.selectedJobs, - swimlaneBucketInterval: explorerState.swimlaneBucketInterval, - tableInterval: tableInterval.val, - tableSeverity: tableSeverity.val, - viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName, - swimlaneContainerWidth: explorerState.swimlaneContainerWidth, - viewByPerPage: explorerState.viewByPerPage, - viewByFromPage: explorerState.viewByFromPage, - }) || - undefined; + explorerState !== undefined + ? { + bounds: explorerState.bounds, + lastRefresh, + influencersFilterQuery: explorerState.influencersFilterQuery, + noInfluencersConfigured: explorerState.noInfluencersConfigured, + selectedCells, + selectedJobs: explorerState.selectedJobs, + swimlaneBucketInterval: explorerState.swimlaneBucketInterval, + tableInterval: tableInterval.val, + tableSeverity: tableSeverity.val, + viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName, + swimlaneContainerWidth: explorerState.swimlaneContainerWidth, + viewByPerPage: explorerState.viewByPerPage, + viewByFromPage: explorerState.viewByFromPage, + } + : undefined; useEffect(() => { if (explorerState && explorerState.swimlaneContainerWidth > 0) { diff --git a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx index d91ec27d9a505a..6e14fc345e97f5 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx @@ -56,7 +56,7 @@ const PageWrapper: FC = ({ deps }) => { refreshValue === 0 && refreshPause === true ? { pause: false, value: DEFAULT_REFRESH_INTERVAL_MS } : { pause: refreshPause, value: refreshValue }; - setGlobalState({ refreshInterval }); + setGlobalState({ refreshInterval }, undefined, true); timefilter.setRefreshInterval(refreshInterval); }, []); const annotationUpdatesService = useMemo(() => new AnnotationUpdatesService(), []); diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index f0fb4558bcfa95..df92c772525659 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -41,6 +41,9 @@ import { useTimefilter } from '../../contexts/kibana'; import { useToastNotificationService } from '../../services/toast_notification_service'; import { AnnotationUpdatesService } from '../../services/annotations_service'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; +import { useTimeSeriesExplorerUrlState } from '../../timeseriesexplorer/hooks/use_timeseriesexplorer_url_state'; +import { TimeSeriesExplorerAppState } from '../../../../common/types/ml_url_generator'; + export const timeSeriesExplorerRouteFactory = ( navigateToPath: NavigateToPath, basePath: string @@ -79,10 +82,7 @@ const PageWrapper: FC = ({ deps }) => { ); }; -interface AppStateZoom { - from: string; - to: string; -} +type AppStateZoom = Exclude['zoom']; interface TimeSeriesExplorerUrlStateManager { config: any; @@ -94,7 +94,10 @@ export const TimeSeriesExplorerUrlStateManager: FC { const toastNotificationService = useToastNotificationService(); - const [appState, setAppState] = useUrlState('_a'); + const [ + timeSeriesExplorerUrlState, + setTimeSeriesExplorerUrlState, + ] = useTimeSeriesExplorerUrlState(); const [globalState, setGlobalState] = useUrlState('_g'); const [lastRefresh, setLastRefresh] = useState(0); const previousRefresh = usePrevious(lastRefresh); @@ -148,7 +151,7 @@ export const TimeSeriesExplorerUrlStateManager: FC { - const mlTimeSeriesExplorer = - appState?.mlTimeSeriesExplorer !== undefined ? { ...appState.mlTimeSeriesExplorer } : {}; + /** + * Empty zoom indicates that chart hasn't been rendered yet, + * hence any updates prior that should replace the URL state. + */ + const isInitUpdate = timeSeriesExplorerUrlState?.mlTimeSeriesExplorer?.zoom === undefined; + + const mlTimeSeriesExplorer: TimeSeriesExplorerAppState['mlTimeSeriesExplorer'] = + timeSeriesExplorerUrlState?.mlTimeSeriesExplorer !== undefined + ? { ...timeSeriesExplorerUrlState.mlTimeSeriesExplorer } + : {}; switch (action) { case APP_STATE_ACTION.CLEAR: @@ -222,9 +235,12 @@ export const TimeSeriesExplorerUrlStateManager: FC( - appState?.mlTimeSeriesExplorer?.forecastId + timeSeriesExplorerUrlState?.mlTimeSeriesExplorer?.forecastId ); useEffect(() => { diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index 20edd07556c074..32fa27b70989e7 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -395,13 +395,7 @@ class TimeseriesChartIntl extends Component { contextChartInitialized = false; drawContextChartSelection() { - const { - contextChartData, - contextChartSelected, - contextForecastData, - zoomFrom, - zoomTo, - } = this.props; + const { contextChartData, contextForecastData, zoomFrom, zoomTo } = this.props; if (contextChartData === undefined) { return; @@ -455,10 +449,6 @@ class TimeseriesChartIntl extends Component { new Date(contextXScaleDomain[0]), new Date(contextXScaleDomain[1]) ); - if (this.contextChartInitialized === false) { - this.contextChartInitialized = true; - contextChartSelected({ from: contextXScaleDomain[0], to: contextXScaleDomain[1] }); - } } } } diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/hooks/use_timeseriesexplorer_url_state.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/hooks/use_timeseriesexplorer_url_state.ts new file mode 100644 index 00000000000000..7faa6e2b83514f --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/hooks/use_timeseriesexplorer_url_state.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { usePageUrlState } from '../../util/url_state'; +import { TimeSeriesExplorerAppState } from '../../../../common/types/ml_url_generator'; +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; + +export function useTimeSeriesExplorerUrlState() { + return usePageUrlState(ML_PAGES.SINGLE_METRIC_VIEWER); +} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts index 530ba567ed9f78..8159dbb8ade064 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts @@ -16,9 +16,9 @@ declare const TimeSeriesExplorer: FC<{ lastRefresh: number; selectedJobId: string; selectedDetectorIndex: number; - selectedEntities: any[]; + selectedEntities: Record | undefined; selectedForecastId?: string; tableInterval: string; tableSeverity: number; - zoom?: { from: string; to: string }; + zoom?: { from?: string; to?: string }; }>; diff --git a/x-pack/plugins/ml/public/application/util/url_state.tsx b/x-pack/plugins/ml/public/application/util/url_state.tsx index 448a888ab32c23..fdc6dd135cd69c 100644 --- a/x-pack/plugins/ml/public/application/util/url_state.tsx +++ b/x-pack/plugins/ml/public/application/util/url_state.tsx @@ -19,7 +19,8 @@ type Accessor = '_a' | '_g'; export type SetUrlState = ( accessor: Accessor, attribute: string | Dictionary, - value?: any + value?: any, + replaceState?: boolean ) => void; export interface UrlState { searchString: string; @@ -78,7 +79,12 @@ export const UrlStateProvider: FC = ({ children }) => { const { search: searchString } = useLocation(); const setUrlState: SetUrlState = useCallback( - (accessor: Accessor, attribute: string | Dictionary, value?: any) => { + ( + accessor: Accessor, + attribute: string | Dictionary, + value?: any, + replaceState?: boolean + ) => { const prevSearchString = searchString; const urlState = parseUrlState(prevSearchString); const parsedQueryString = parse(prevSearchString, { sort: false }); @@ -120,7 +126,11 @@ export const UrlStateProvider: FC = ({ children }) => { if (oldLocationSearchString !== newLocationSearchString) { const newSearchString = stringify(parsedQueryString, { sort: false }); - history.push({ search: newSearchString }); + if (replaceState) { + history.replace({ search: newSearchString }); + } else { + history.push({ search: newSearchString }); + } } } catch (error) { // eslint-disable-next-line no-console @@ -144,37 +154,43 @@ export const useUrlState = (accessor: Accessor) => { }, [searchString]); const setUrlState = useCallback( - (attribute: string | Dictionary, value?: any) => { - setUrlStateContext(accessor, attribute, value); + (attribute: string | Dictionary, value?: any, replaceState?: boolean) => { + setUrlStateContext(accessor, attribute, value, replaceState); }, [accessor, setUrlStateContext] ); return [urlState, setUrlState]; }; +type AppStateKey = 'mlSelectSeverity' | 'mlSelectInterval' | MlPages; + /** * Hook for managing the URL state of the page. */ export const usePageUrlState = ( - pageKey: MlPages, - defaultState: PageUrlState -): [PageUrlState, (update: Partial) => void] => { + pageKey: AppStateKey, + defaultState?: PageUrlState +): [PageUrlState, (update: Partial, replaceState?: boolean) => void] => { const [appState, setAppState] = useUrlState('_a'); const pageState = appState?.[pageKey]; const resultPageState: PageUrlState = useMemo(() => { return { - ...defaultState, + ...(defaultState ?? {}), ...(pageState ?? {}), }; }, [pageState]); const onStateUpdate = useCallback( - (update: Partial, replace?: boolean) => { - setAppState(pageKey, { - ...(replace ? {} : resultPageState), - ...update, - }); + (update: Partial, replaceState?: boolean) => { + setAppState( + pageKey, + { + ...resultPageState, + ...update, + }, + replaceState + ); }, [pageKey, resultPageState, setAppState] ); diff --git a/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts index d53dfa8fd19c9f..d2814bd63b0b03 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts @@ -132,9 +132,9 @@ export function createExplorerUrl( { useHash: false, storeInHashQuery: false }, url ); - url = setStateToKbnUrl>( + url = setStateToKbnUrl>>( '_a', - appState, + { [ML_PAGES.ANOMALY_EXPLORER]: appState }, { useHash: false, storeInHashQuery: false }, url ); @@ -157,7 +157,6 @@ export function createSingleMetricViewerUrl( timeRange, jobIds, refreshInterval, - zoom, query, detectorIndex, forecastId, @@ -196,7 +195,6 @@ export function createSingleMetricViewerUrl( appState.mlTimeSeriesExplorer = mlTimeSeriesExplorer; - if (zoom) appState.zoom = zoom; if (query) appState.query = { query_string: query, @@ -207,9 +205,9 @@ export function createSingleMetricViewerUrl( { useHash: false, storeInHashQuery: false }, url ); - url = setStateToKbnUrl( + url = setStateToKbnUrl>>( '_a', - appState, + { [ML_PAGES.SINGLE_METRIC_VIEWER]: appState }, { useHash: false, storeInHashQuery: false }, url ); diff --git a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts index 3f3d88f1a31d99..7dcd901c2c0ef4 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts @@ -87,7 +87,7 @@ describe('MlUrlGenerator', () => { }, }); expect(url).toBe( - "/app/ml/explorer?_g=(ml:(jobIds:!(fq_single_1)),refreshInterval:(pause:!f,value:0),time:(from:'2019-02-07T00:00:00.000Z',mode:absolute,to:'2020-08-13T17:15:00.000Z'))&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFromPage:2,viewByPerPage:20),query:(analyze_wildcard:!t,query:'*'))" + "/app/ml/explorer?_g=(ml:(jobIds:!(fq_single_1)),refreshInterval:(pause:!f,value:0),time:(from:'2019-02-07T00:00:00.000Z',mode:absolute,to:'2020-08-13T17:15:00.000Z'))&_a=(explorer:(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFromPage:2,viewByPerPage:20),query:(analyze_wildcard:!t,query:'*')))" ); }); it('should generate valid URL for the Anomaly Explorer page for multiple jobIds', async () => { @@ -103,7 +103,7 @@ describe('MlUrlGenerator', () => { }, }); expect(url).toBe( - "/app/ml/explorer?_g=(ml:(jobIds:!(fq_single_1,logs_categorization_1)),time:(from:'2019-02-07T00:00:00.000Z',mode:absolute,to:'2020-08-13T17:15:00.000Z'))&_a=(mlExplorerFilter:(),mlExplorerSwimlane:())" + "/app/ml/explorer?_g=(ml:(jobIds:!(fq_single_1,logs_categorization_1)),time:(from:'2019-02-07T00:00:00.000Z',mode:absolute,to:'2020-08-13T17:15:00.000Z'))&_a=(explorer:(mlExplorerFilter:(),mlExplorerSwimlane:()))" ); }); }); @@ -130,7 +130,7 @@ describe('MlUrlGenerator', () => { }, }); expect(url).toBe( - "/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(logs_categorization_1)),refreshInterval:(pause:!f,value:0),time:(from:'2020-07-12T00:39:02.912Z',mode:absolute,to:'2020-07-22T15:52:18.613Z'))&_a=(mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*')))" + "/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(logs_categorization_1)),refreshInterval:(pause:!f,value:0),time:(from:'2020-07-12T00:39:02.912Z',mode:absolute,to:'2020-07-22T15:52:18.613Z'))&_a=(timeseriesexplorer:(mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*'))))" ); }); @@ -161,7 +161,7 @@ describe('MlUrlGenerator', () => { }, }); expect(url).toBe( - "/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(logs_categorization_1)),refreshInterval:(pause:!f,value:0),time:(from:'2020-07-12T00:39:02.912Z',mode:absolute,to:'2020-07-22T15:52:18.613Z'))&_a=(mlTimeSeriesExplorer:(detectorIndex:0,entities:(mlcategory:'2')),query:(query_string:(analyze_wildcard:!t,query:'*')),zoom:(from:'2020-07-20T23:58:29.367Z',to:'2020-07-21T11:00:13.173Z'))" + "/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(logs_categorization_1)),refreshInterval:(pause:!f,value:0),time:(from:'2020-07-12T00:39:02.912Z',mode:absolute,to:'2020-07-22T15:52:18.613Z'))&_a=(timeseriesexplorer:(mlTimeSeriesExplorer:(detectorIndex:0,entities:(mlcategory:'2')),query:(query_string:(analyze_wildcard:!t,query:'*'))))" ); }); }); diff --git a/x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts b/x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts index 8e5a6ef64e59fe..ad4aabd17f6536 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts @@ -7,12 +7,19 @@ import { useEffect, useState } from 'react'; import { MlPluginStart } from '../index'; import { MlUrlGeneratorState } from '../../common/types/ml_url_generator'; + +/** + * Provides a URL to ML plugin page + * TODO remove basePath parameter + */ export const useMlHref = ( ml: MlPluginStart | undefined, - basePath: string, + basePath: string | undefined, params: MlUrlGeneratorState ) => { - const [mlLink, setMlLink] = useState(`${basePath}/app/ml/${params.page}`); + const [mlLink, setMlLink] = useState( + basePath !== undefined ? `${basePath}/app/ml/${params.page}` : undefined + ); useEffect(() => { let isCancelled = false; diff --git a/x-pack/plugins/ml/server/lib/spaces_utils.ts b/x-pack/plugins/ml/server/lib/spaces_utils.ts index b96fe6f2d1eb64..ecff3b8124cf5f 100644 --- a/x-pack/plugins/ml/server/lib/spaces_utils.ts +++ b/x-pack/plugins/ml/server/lib/spaces_utils.ts @@ -7,6 +7,7 @@ import { Legacy } from 'kibana'; import { KibanaRequest } from '../../../../../src/core/server'; import { SpacesPluginStart } from '../../../spaces/server'; +import { PLUGIN_ID } from '../../common/constants/app'; export type RequestFacade = KibanaRequest | Legacy.Request; @@ -22,19 +23,34 @@ export function spacesUtilsProvider( const space = await (await getSpacesPlugin()).spacesService.getActiveSpace( request instanceof KibanaRequest ? request : KibanaRequest.from(request) ); - return space.disabledFeatures.includes('ml') === false; + return space.disabledFeatures.includes(PLUGIN_ID) === false; } - async function getAllSpaces(): Promise { + async function getAllSpaces() { if (getSpacesPlugin === undefined) { return null; } const client = (await getSpacesPlugin()).spacesService.createSpacesClient( request instanceof KibanaRequest ? request : KibanaRequest.from(request) ); - const spaces = await client.getAll(); + return await client.getAll(); + } + + async function getAllSpaceIds(): Promise { + const spaces = await getAllSpaces(); + if (spaces === null) { + return null; + } return spaces.map((s) => s.id); } - return { isMlEnabledInSpace, getAllSpaces }; + async function getMlSpaceIds(): Promise { + const spaces = await getAllSpaces(); + if (spaces === null) { + return null; + } + return spaces.filter((s) => s.disabledFeatures.includes(PLUGIN_ID) === false).map((s) => s.id); + } + + return { isMlEnabledInSpace, getAllSpaces, getAllSpaceIds, getMlSpaceIds }; } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index f875788d50c5ed..aeaf13ebf954e3 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -1095,7 +1095,9 @@ export class DataRecognizer { job.config.analysis_limits.model_memory_limit = modelMemoryLimit; } } catch (error) { - mlLog.warn(`Data recognizer could not estimate model memory limit ${error.body}`); + mlLog.warn( + `Data recognizer could not estimate model memory limit ${JSON.stringify(error.body)}` + ); } } diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 5e103dbc1806ac..e48983c1c53656 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -178,7 +178,10 @@ export class MlServerPlugin notificationRoutes(routeInit); resultsServiceRoutes(routeInit); jobValidationRoutes(routeInit, this.version); - savedObjectsRoutes(routeInit); + savedObjectsRoutes(routeInit, { + getSpaces, + resolveMlCapabilities, + }); systemRoutes(routeInit, { getSpaces, cloud: plugins.cloud, diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index c157ae9e8200fb..5672824f3d040c 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -150,6 +150,7 @@ "AssignJobsToSpaces", "RemoveJobsFromSpaces", "JobsSpaces", + "DeleteJobCheck", "TrainedModels", "GetTrainedModel", diff --git a/x-pack/plugins/ml/server/routes/saved_objects.ts b/x-pack/plugins/ml/server/routes/saved_objects.ts index 1c9c975b662699..3ba69b0d6b5058 100644 --- a/x-pack/plugins/ml/server/routes/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/saved_objects.ts @@ -5,14 +5,18 @@ */ import { wrapError } from '../client/error_wrapper'; -import { RouteInitialization } from '../types'; +import { RouteInitialization, SavedObjectsRouteDeps } from '../types'; import { checksFactory, repairFactory } from '../saved_objects'; -import { jobsAndSpaces, repairJobObjects } from './schemas/saved_objects'; +import { jobsAndSpaces, repairJobObjects, jobTypeSchema } from './schemas/saved_objects'; +import { jobIdsSchema } from './schemas/job_service_schema'; /** * Routes for job saved object management */ -export function savedObjectsRoutes({ router, routeGuard }: RouteInitialization) { +export function savedObjectsRoutes( + { router, routeGuard }: RouteInitialization, + { getSpaces, resolveMlCapabilities }: SavedObjectsRouteDeps +) { /** * @apiGroup JobSavedObjects * @@ -220,4 +224,50 @@ export function savedObjectsRoutes({ router, routeGuard }: RouteInitialization) } }) ); + + /** + * @apiGroup JobSavedObjects + * + * @api {get} /api/ml/saved_objects/delete_job_check Check whether user can delete a job + * @apiName DeleteJobCheck + * @apiDescription Check the user's ability to delete jobs. Returns whether they are able + * to fully delete the job and whether they are able to remove it from + * the current space. + * + * @apiSchema (body) jobIdsSchema (params) jobTypeSchema + * + */ + router.post( + { + path: '/api/ml/saved_objects/can_delete_job/{jobType}', + validate: { + params: jobTypeSchema, + body: jobIdsSchema, + }, + options: { + tags: ['access:ml:canGetJobs', 'access:ml:canGetDataFrameAnalytics'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService, client }) => { + try { + const { jobType } = request.params; + const { jobIds }: { jobIds: string[] } = request.body; + + const { canDeleteJobs } = checksFactory(client, jobSavedObjectService); + const body = await canDeleteJobs( + request, + jobType, + jobIds, + getSpaces !== undefined, + resolveMlCapabilities + ); + + return response.ok({ + body, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); } diff --git a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts index d7385f6468f465..6b8c64714a82cc 100644 --- a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts @@ -13,3 +13,7 @@ export const jobsAndSpaces = schema.object({ }); export const repairJobObjects = schema.object({ simulate: schema.maybe(schema.boolean()) }); + +export const jobTypeSchema = schema.object({ + jobType: schema.string(), +}); diff --git a/x-pack/plugins/ml/server/saved_objects/authorization.ts b/x-pack/plugins/ml/server/saved_objects/authorization.ts index 815ff29ae010c0..958ee2091f11e6 100644 --- a/x-pack/plugins/ml/server/saved_objects/authorization.ts +++ b/x-pack/plugins/ml/server/saved_objects/authorization.ts @@ -6,6 +6,7 @@ import { KibanaRequest } from 'kibana/server'; import type { SecurityPluginSetup } from '../../../security/server'; +import { ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; export function authorizationProvider(authorization: SecurityPluginSetup['authz']) { async function authorizationCheck(request: KibanaRequest) { @@ -18,7 +19,7 @@ export function authorizationProvider(authorization: SecurityPluginSetup['authz' request ); const createMLJobAuthorizationAction = authorization.actions.savedObject.get( - 'ml-job', + ML_SAVED_OBJECT_TYPE, 'create' ); const canCreateGlobally = ( diff --git a/x-pack/plugins/ml/server/saved_objects/checks.ts b/x-pack/plugins/ml/server/saved_objects/checks.ts index 51269599105da2..f682999cd59666 100644 --- a/x-pack/plugins/ml/server/saved_objects/checks.ts +++ b/x-pack/plugins/ml/server/saved_objects/checks.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IScopedClusterClient } from 'kibana/server'; +import Boom from '@hapi/boom'; +import { IScopedClusterClient, KibanaRequest } from 'kibana/server'; import type { JobSavedObjectService } from './service'; -import { JobType } from '../../common/types/saved_objects'; +import { JobType, DeleteJobCheckResponse } from '../../common/types/saved_objects'; import { Job } from '../../common/types/anomaly_detection_jobs'; import { Datafeed } from '../../common/types/anomaly_detection_jobs'; import { DataFrameAnalyticsConfig } from '../../common/types/data_frame_analytics'; +import { ResolveMlCapabilities } from '../../common/types/capabilities'; interface JobSavedObjectStatus { jobId: string; @@ -154,5 +156,105 @@ export function checksFactory( }; } - return { checkStatus }; + async function canDeleteJobs( + request: KibanaRequest, + jobType: JobType, + jobIds: string[], + spacesEnabled: boolean, + resolveMlCapabilities: ResolveMlCapabilities + ) { + if (jobType !== 'anomaly-detector' && jobType !== 'data-frame-analytics') { + throw Boom.badRequest('Job type must be "anomaly-detector" or "data-frame-analytics"'); + } + + const mlCapabilities = await resolveMlCapabilities(request); + if (mlCapabilities === null) { + throw Boom.internal('mlCapabilities is not defined'); + } + + if ( + (jobType === 'anomaly-detector' && mlCapabilities.canDeleteJob === false) || + (jobType === 'data-frame-analytics' && mlCapabilities.canDeleteDataFrameAnalytics === false) + ) { + // user does not have access to delete jobs. + return jobIds.reduce((results, jobId) => { + results[jobId] = { + canDelete: false, + canUntag: false, + }; + return results; + }, {} as DeleteJobCheckResponse); + } + + if (spacesEnabled === false) { + // spaces are disabled, delete only no untagging + return jobIds.reduce((results, jobId) => { + results[jobId] = { + canDelete: true, + canUntag: false, + }; + return results; + }, {} as DeleteJobCheckResponse); + } + const canCreateGlobalJobs = await jobSavedObjectService.canCreateGlobalJobs(request); + + const jobObjects = await Promise.all( + jobIds.map((id) => jobSavedObjectService.getJobObject(jobType, id)) + ); + + return jobIds.reduce((results, jobId) => { + const jobObject = jobObjects.find((j) => j?.attributes.job_id === jobId); + if (jobObject === undefined || jobObject.namespaces === undefined) { + // job saved object not found + results[jobId] = { + canDelete: false, + canUntag: false, + }; + return results; + } + + const { namespaces } = jobObject; + const isGlobalJob = namespaces.includes('*'); + + // job is in * space, user can see all spaces - delete and no option to untag + if (canCreateGlobalJobs && isGlobalJob) { + results[jobId] = { + canDelete: true, + canUntag: false, + }; + return results; + } + + // job is in * space, user cannot see all spaces - no untagging, no deleting + if (isGlobalJob) { + results[jobId] = { + canDelete: false, + canUntag: false, + }; + return results; + } + + // jobs with are in individual spaces can only be untagged + // from current space if the job is in more than 1 space + const canUntag = namespaces.length > 1; + + // job is in individual spaces, user cannot see all of them - untag only, no delete + if (namespaces.includes('?')) { + results[jobId] = { + canDelete: false, + canUntag, + }; + return results; + } + + // job is individual spaces, user can see all of them - delete and option to untag + results[jobId] = { + canDelete: true, + canUntag, + }; + return results; + }, {} as DeleteJobCheckResponse); + } + + return { checkStatus, canDeleteJobs }; } diff --git a/x-pack/plugins/ml/server/saved_objects/service.ts b/x-pack/plugins/ml/server/saved_objects/service.ts index ecaf0869d196c7..bfc5b165fe555a 100644 --- a/x-pack/plugins/ml/server/saved_objects/service.ts +++ b/x-pack/plugins/ml/server/saved_objects/service.ts @@ -5,7 +5,12 @@ */ import RE2 from 're2'; -import { KibanaRequest, SavedObjectsClientContract, SavedObjectsFindOptions } from 'kibana/server'; +import { + KibanaRequest, + SavedObjectsClientContract, + SavedObjectsFindOptions, + SavedObjectsFindResult, +} from 'kibana/server'; import type { SecurityPluginSetup } from '../../../security/server'; import { JobType, ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; import { MLJobNotFound } from '../lib/ml_client'; @@ -133,6 +138,15 @@ export function jobSavedObjectServiceFactory( return await _getJobObjects(jobType, undefined, undefined, currentSpaceOnly); } + async function getJobObject( + jobType: JobType, + jobId: string, + currentSpaceOnly: boolean = true + ): Promise | undefined> { + const [jobObject] = await _getJobObjects(jobType, jobId, undefined, currentSpaceOnly); + return jobObject; + } + async function getAllJobObjectsForAllSpaces(jobType?: JobType) { await isMlReady(); const filterObject: JobObjectFilter = {}; @@ -307,6 +321,7 @@ export function jobSavedObjectServiceFactory( return { getAllJobObjects, + getJobObject, createAnomalyDetectionJob, createDataFrameAnalyticsJob, deleteAnomalyDetectionJob, diff --git a/x-pack/plugins/ml/server/types.ts b/x-pack/plugins/ml/server/types.ts index df40f5a26b0f35..780a4284312e71 100644 --- a/x-pack/plugins/ml/server/types.ts +++ b/x-pack/plugins/ml/server/types.ts @@ -31,6 +31,11 @@ export interface SystemRouteDeps { resolveMlCapabilities: ResolveMlCapabilities; } +export interface SavedObjectsRouteDeps { + getSpaces?: () => Promise; + resolveMlCapabilities: ResolveMlCapabilities; +} + export interface PluginsSetup { cloud: CloudSetup; features: FeaturesPluginSetup; diff --git a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx index b5bfe3eec7d35b..fe668189dcf556 100644 --- a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx +++ b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx @@ -8,5 +8,19 @@ import { useTheme } from './use_theme'; export function useChartTheme() { const theme = useTheme(); - return theme.darkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; + const baseChartTheme = theme.darkMode + ? EUI_CHARTS_THEME_DARK.theme + : EUI_CHARTS_THEME_LIGHT.theme; + + return { + ...baseChartTheme, + background: { + ...baseChartTheme.background, + color: 'transparent', + }, + lineSeriesStyle: { + ...baseChartTheme.lineSeriesStyle, + point: { visible: false }, + }, + }; } diff --git a/x-pack/plugins/reporting/server/browsers/download/download.ts b/x-pack/plugins/reporting/server/browsers/download/download.ts index b4b303416facd0..a8637679a76d70 100644 --- a/x-pack/plugins/reporting/server/browsers/download/download.ts +++ b/x-pack/plugins/reporting/server/browsers/download/download.ts @@ -36,7 +36,7 @@ export async function download(url: string, path: string, logger: GenericLevelLo hash.update(chunk); }); - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { resp.data .on('error', (err: Error) => { logger.error(err); diff --git a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts index 72b42143a24f70..ea65262c090eee 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts @@ -24,7 +24,7 @@ import { createMockReportingCore } from '../../test_helpers'; import { runTaskFnFactory } from './execute_job'; import { TaskPayloadCSV } from './types'; -const delay = (ms: number) => new Promise((resolve) => setTimeout(() => resolve(), ms)); +const delay = (ms: number) => new Promise((resolve) => setTimeout(() => resolve(), ms)); const puid = new Puid(); const getRandomScrollId = () => { diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts index edd4f71b2adacc..429c5d88d7dbbf 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts @@ -36,7 +36,7 @@ export const waitForRenderComplete = async ( const renderedTasks = []; function waitForRender(visualization: Element) { - return new Promise((resolve) => { + return new Promise((resolve) => { visualization.addEventListener('renderComplete', () => resolve()); }); } diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts index 52ce8812454d97..5d48404fca2b75 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts @@ -8,7 +8,7 @@ import { OverlayStart } from 'src/core/public'; import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; import { TagsCapabilities } from '../../common'; import { ITagsCache, ITagInternalClient } from '../tags'; -import { getTagIdsFromReferences, updateTagsReferences } from '../utils'; +import { getTagIdsFromReferences, updateTagsReferences, convertTagNameToId } from '../utils'; import { getComponents } from './components'; import { buildGetTableColumnDefinition } from './get_table_column_definition'; import { buildGetSearchBarFilter } from './get_search_bar_filter'; @@ -39,6 +39,7 @@ export const getUiApi = ({ convertNameToReference: buildConvertNameToReference({ cache }), hasTagDecoration, getTagIdsFromReferences, + getTagIdFromName: (tagName: string) => convertTagNameToId(tagName, cache.getState()), updateTagsReferences, }; }; diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx index c58cb12f03a192..68daf427677ffd 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx @@ -25,7 +25,7 @@ const waitForRender = async ( wrapper: ReactWrapper, condition: (wrapper: ReactWrapper) => boolean ) => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const interval = setInterval(async () => { await Promise.resolve(); wrapper.update(); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx index 98e59eb95f0daf..7e83c3654cac30 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx @@ -413,7 +413,7 @@ export class RoleMappingsGridPage extends Component { 'xpack.security.management.roleMappings.actionDeleteAriaLabel', { defaultMessage: `Delete '{name}'`, - values: { name }, + values: { name: record.name }, } )} iconType="trash" diff --git a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx index cfb57f8a8a8d5c..69229e2aef7eb0 100644 --- a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx @@ -24,7 +24,7 @@ const waitForRender = async ( wrapper: ReactWrapper, condition: (wrapper: ReactWrapper) => boolean ) => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const interval = setInterval(async () => { await Promise.resolve(); wrapper.update(); diff --git a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts index 71ef6496ef6cad..de0c915d0c14c3 100644 --- a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts +++ b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts @@ -34,7 +34,7 @@ afterEach(() => { it(`logs out 401 responses`, async () => { const http = setupHttp('/foo'); const sessionExpired = new SessionExpired(`${http.basePath}/logout`, tenant); - const logoutPromise = new Promise((resolve) => { + const logoutPromise = new Promise((resolve) => { jest.spyOn(sessionExpired, 'logout').mockImplementation(() => resolve()); }); const interceptor = new UnauthorizedResponseHttpInterceptor(sessionExpired, http.anonymousPaths); diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts index 66119e098238ee..ec82f4795158ee 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts @@ -27,7 +27,6 @@ interface Node { } describe('data generator data streams', () => { - // these tests cast the result of the generate methods so that we can specifically compare the `data_stream` fields it('creates a generator with default data streams', () => { const generator = new EndpointDocGenerator('seed'); expect(generator.generateHostMetadata().data_stream).toEqual({ @@ -268,6 +267,31 @@ describe('data generator', () => { } }; + it('sets the start and end times correctly', () => { + const startOfEpoch = new Date(0); + let startTime = new Date(timestampSafeVersion(tree.allEvents[0]) ?? startOfEpoch); + expect(startTime).not.toEqual(startOfEpoch); + let endTime = new Date(timestampSafeVersion(tree.allEvents[0]) ?? startOfEpoch); + expect(startTime).not.toEqual(startOfEpoch); + + for (const event of tree.allEvents) { + const currentEventTime = new Date(timestampSafeVersion(event) ?? startOfEpoch); + expect(currentEventTime).not.toEqual(startOfEpoch); + expect(tree.startTime.getTime()).toBeLessThanOrEqual(currentEventTime.getTime()); + expect(tree.endTime.getTime()).toBeGreaterThanOrEqual(currentEventTime.getTime()); + if (currentEventTime < startTime) { + startTime = currentEventTime; + } + + if (currentEventTime > endTime) { + endTime = currentEventTime; + } + } + expect(startTime).toEqual(tree.startTime); + expect(endTime).toEqual(tree.endTime); + expect(endTime.getTime() - startTime.getTime()).toBeGreaterThanOrEqual(0); + }); + it('creates related events in ascending order', () => { // the order should not change since it should already be in ascending order const relatedEventsAsc = _.cloneDeep(tree.origin.relatedEvents).sort( diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index a4bdc4fc59a7cb..3c508bed5b2f1b 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -317,6 +317,8 @@ export interface Tree { * All events from children, ancestry, origin, and the alert in a single array */ allEvents: Event[]; + startTime: Date; + endTime: Date; } export interface TreeOptions { @@ -718,6 +720,35 @@ export class EndpointDocGenerator { }; } + private static getStartEndTimes(events: Event[]): { startTime: Date; endTime: Date } { + let startTime: number; + let endTime: number; + if (events.length > 0) { + startTime = timestampSafeVersion(events[0]) ?? new Date().getTime(); + endTime = startTime; + } else { + startTime = new Date().getTime(); + endTime = startTime; + } + + for (const event of events) { + const eventTimestamp = timestampSafeVersion(event); + if (eventTimestamp !== undefined) { + if (eventTimestamp < startTime) { + startTime = eventTimestamp; + } + + if (eventTimestamp > endTime) { + endTime = eventTimestamp; + } + } + } + return { + startTime: new Date(startTime), + endTime: new Date(endTime), + }; + } + /** * This generates a full resolver tree and keeps the entire tree in memory. This is useful for tests that want * to compare results from elasticsearch with the actual events created by this generator. Because all the events @@ -815,12 +846,17 @@ export class EndpointDocGenerator { const childrenByParent = groupNodesByParent(childrenNodes); const levels = createLevels(childrenByParent, [], childrenByParent.get(origin.id)); + const allEvents = [...ancestry, ...children]; + const { startTime, endTime } = EndpointDocGenerator.getStartEndTimes(allEvents); + return { children: childrenNodes, ancestry: ancestryNodes, - allEvents: [...ancestry, ...children], + allEvents, origin, childrenLevels: levels, + startTime, + endTime, }; } diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts index 1dd5668b3177a1..6777b1dabbd538 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -7,9 +7,9 @@ import { schema } from '@kbn/config-schema'; /** - * Used to validate GET requests for a complete resolver tree. + * Used to validate GET requests for a complete resolver tree centered around an entity_id. */ -export const validateTree = { +export const validateTreeEntityID = { params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ children: schema.number({ defaultValue: 200, min: 0, max: 10000 }), @@ -23,6 +23,44 @@ export const validateTree = { }), }; +/** + * Used to validate GET requests for a complete resolver tree. + */ +export const validateTree = { + body: schema.object({ + /** + * If the ancestry field is specified this field will be ignored + * + * If the ancestry field is specified we have a much more performant way of retrieving levels so let's not limit + * the number of levels that come back in that scenario. We could still limit it, but what we'd likely have to do + * is get all the levels back like we normally do with the ancestry array, bucket them together by level, and then + * remove the levels that exceeded the requested number which seems kind of wasteful. + */ + descendantLevels: schema.number({ defaultValue: 20, min: 0, max: 1000 }), + descendants: schema.number({ defaultValue: 1000, min: 0, max: 10000 }), + // if the ancestry array isn't specified allowing 200 might be too high + ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), + timerange: schema.object({ + from: schema.string(), + to: schema.string(), + }), + schema: schema.object({ + // the ancestry field is optional + ancestry: schema.maybe(schema.string({ minLength: 1 })), + id: schema.string({ minLength: 1 }), + name: schema.maybe(schema.string({ minLength: 1 })), + parent: schema.string({ minLength: 1 }), + }), + // only allowing strings and numbers for node IDs because Elasticsearch only allows those types for collapsing: + // https://www.elastic.co/guide/en/elasticsearch/reference/current/collapse-search-results.html + // We use collapsing in our Elasticsearch queries for the tree api + nodes: schema.arrayOf(schema.oneOf([schema.string({ minLength: 1 }), schema.number()]), { + minSize: 1, + }), + indexPatterns: schema.arrayOf(schema.string(), { minSize: 1 }), + }), +}; + /** * Used to validate POST requests for `/resolver/events` api. */ diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index e7d060b463aba6..cd5c60e2698cb9 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -78,6 +78,56 @@ export interface EventStats { byCategory: Record; } +/** + * Represents the object structure of a returned document when using doc value fields to filter the fields + * returned in a document from an Elasticsearch query. + * + * Here is an example: + * + * { + * "_index": ".ds-logs-endpoint.events.process-default-000001", + * "_id": "bc7brnUBxO0aE7QcCVHo", + * "_score": null, + * "fields": { <----------- The FieldsObject represents this portion + * "@timestamp": [ + * "2020-11-09T21:13:25.246Z" + * ], + * "process.name": "explorer.exe", + * "process.parent.entity_id": [ + * "0i17c2m22c" + * ], + * "process.Ext.ancestry": [ <------------ Notice that the keys are flattened + * "0i17c2m22c", + * "2z9j8dlx72", + * "oj61pr6g62", + * "x0leonbrc9" + * ], + * "process.entity_id": [ + * "6k8waczi22" + * ] + * }, + * "sort": [ + * 0, + * 1604956405246 + * ] + * } + */ +export interface FieldsObject { + [key: string]: ECSField; +} + +/** + * A node in a resolver graph. + */ +export interface ResolverNode { + data: FieldsObject; + id: string | number; + // the very root node might not have the parent field defined + parent?: string | number; + name?: string; + stats: EventStats; +} + /** * Statistical information for a node in a resolver tree. */ diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/index.ts index 84a5d868c34a92..750cda54b0c210 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/index.ts @@ -37,6 +37,7 @@ export interface MatrixHistogramRequestOptions extends RequestBasicOptions { stackByField: string; threshold?: { field: string | undefined; value: number } | undefined; inspect?: Maybe; + isPtrIncluded?: boolean; } export interface MatrixHistogramStrategyResponse extends IEsSearchResponse { diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index e7a4e7091180af..5150a907ae712a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -12,7 +12,7 @@ import { TestProviders } from '../../../common/mock'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; import { - ActionsConnectorsContextProvider, + ActionConnector, ConnectorAddFlyout, ConnectorEditFlyout, TriggersAndActionsUIPublicPluginStart, @@ -41,6 +41,47 @@ describe('ConfigureCases', () => { beforeEach(() => { useKibanaMock().services.triggersActionsUi = ({ actionTypeRegistry: actionTypeRegistryMock.create(), + getAddConnectorFlyout: jest.fn().mockImplementation(() => ( + {}} + actionTypeRegistry={actionTypeRegistryMock.create()} + actionTypes={[ + { + id: '.servicenow', + name: 'servicenow', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + }, + { + id: '.jira', + name: 'jira', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + }, + { + id: '.resilient', + name: 'resilient', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + }, + ]} + /> + )), + getEditConnectorFlyout: jest + .fn() + .mockImplementation(() => ( + {}} + actionTypeRegistry={actionTypeRegistryMock.create()} + initialConnector={connectors[1] as ActionConnector} + /> + )), } as unknown) as TriggersAndActionsUIPublicPluginStart; }); @@ -62,11 +103,6 @@ describe('ConfigureCases', () => { expect(wrapper.find('[data-test-subj="closure-options-radio-group"]').exists()).toBeTruthy(); }); - test('it renders the ActionsConnectorsContextProvider', () => { - // Components from triggersActionsUi do not have a data-test-subj - expect(wrapper.find(ActionsConnectorsContextProvider).exists()).toBeTruthy(); - }); - test('it does NOT render the ConnectorAddFlyout', () => { // Components from triggersActionsUi do not have a data-test-subj expect(wrapper.find(ConnectorAddFlyout).exists()).toBeFalsy(); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx index 4bf774dde2373f..1c24738f2b2c0a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled, { css } from 'styled-components'; import { EuiCallOut } from '@elastic/eui'; @@ -12,12 +12,7 @@ import { EuiCallOut } from '@elastic/eui'; import { useKibana } from '../../../common/lib/kibana'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; -import { - ActionsConnectorsContextProvider, - ActionType, - ConnectorAddFlyout, - ConnectorEditFlyout, -} from '../../../../../triggers_actions_ui/public'; +import { ActionType } from '../../../../../triggers_actions_ui/public'; import { ClosureType } from '../../containers/configure/types'; @@ -61,7 +56,7 @@ interface ConfigureCasesComponentProps { } const ConfigureCasesComponent: React.FC = ({ userCanCrud }) => { - const { http, triggersActionsUi, notifications, application, docLinks } = useKibana().services; + const { triggersActionsUi } = useKibana().services; const [connectorIsValid, setConnectorIsValid] = useState(true); const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); @@ -155,6 +150,32 @@ const ConfigureCasesComponent: React.FC = ({ userC } }, [connectors, connector, isLoadingConnectors]); + const ConnectorAddFlyout = useMemo( + () => + triggersActionsUi.getAddConnectorFlyout({ + consumer: 'case', + onClose: onCloseAddFlyout, + actionTypes, + reloadConnectors, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const ConnectorEditFlyout = useMemo( + () => + editedConnectorItem && editFlyoutVisible + ? triggersActionsUi.getEditConnectorFlyout({ + initialConnector: editedConnectorItem, + consumer: 'case', + onClose: onCloseEditFlyout, + reloadConnectors, + }) + : null, + // eslint-disable-next-line react-hooks/exhaustive-deps + [connector.id, editFlyoutVisible] + ); + return ( {!connectorIsValid && ( @@ -187,28 +208,8 @@ const ConfigureCasesComponent: React.FC = ({ userC selectedConnector={connector.id} /> - - {addFlyoutVisible && ( - - )} - {editedConnectorItem && editFlyoutVisible && ( - - )} - + {addFlyoutVisible && ConnectorAddFlyout} + {ConnectorEditFlyout} ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx index 3b63bddb204ea9..7902c7065d9a35 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx @@ -182,7 +182,9 @@ describe('Create case', () => { ); }); }); - describe('Step 2 - Connector Fields', () => { + + // FAILED ES PROMOTION: https://github.com/elastic/kibana/issues/84145 + describe.skip('Step 2 - Connector Fields', () => { const connectorTypes = [ { label: 'Jira', diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx index fb1ed956dfc52c..5a50442f8dd5f6 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx @@ -47,6 +47,9 @@ const checkIfAnyValidSeriesExist = ( !checkIfAllValuesAreZero(data) && data.some(checkIfAllTheDataInTheSeriesAreValid); +const yAccessors = ['y']; +const splitSeriesAccessors = ['g']; + // Bar chart rotation: https://ela.st/chart-rotations export const BarChartBaseComponent = ({ data, @@ -86,9 +89,9 @@ export const BarChartBaseComponent = ({ xScaleType={getOr(ScaleType.Linear, 'configs.series.xScaleType', chartConfigs)} yScaleType={getOr(ScaleType.Linear, 'configs.series.yScaleType', chartConfigs)} xAccessor="x" - yAccessors={['y']} + yAccessors={yAccessors} timeZone={timeZone} - splitSeriesAccessors={['g']} + splitSeriesAccessors={splitSeriesAccessors} data={series.value!} stackAccessors={get('configs.series.stackAccessors', chartConfigs)} color={series.color ? series.color : undefined} diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx index e7d7e60a3c4088..5f567508a40116 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx @@ -64,6 +64,7 @@ const HistogramPanel = styled(Panel)<{ height?: number }>` export const MatrixHistogramComponent: React.FC = ({ chartHeight, defaultStackByOption, + docValueFields, endDate, errorMessage, filterQuery, @@ -72,6 +73,7 @@ export const MatrixHistogramComponent: React.FC = hideHistogramIfEmpty = false, id, indexNames, + isPtrIncluded, legendPosition, mapping, panelHeight = DEFAULT_PANEL_HEIGHT, @@ -138,6 +140,8 @@ export const MatrixHistogramComponent: React.FC = indexNames, startDate, stackByField: selectedStackByOption.value, + isPtrIncluded, + docValueFields, }); const titleWithStackByField = useMemo( diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts index 327c2fa64997de..713c5d4738fd2b 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts @@ -12,6 +12,7 @@ import { InputsModelId } from '../../store/inputs/constants'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { UpdateDateRange } from '../charts/common'; import { GlobalTimeArgs } from '../../containers/use_global_time'; +import { DocValueFields } from '../../../../common/search_strategy'; export type MatrixHistogramMappingTypes = Record< string, @@ -57,6 +58,7 @@ interface MatrixHistogramBasicProps { } export interface MatrixHistogramQueryProps { + docValueFields?: DocValueFields[]; endDate: string; errorMessage: string; indexNames: string[]; @@ -72,6 +74,7 @@ export interface MatrixHistogramQueryProps { histogramType: MatrixHistogramType; threshold?: { field: string | undefined; value: number } | undefined; skip?: boolean; + isPtrIncluded?: boolean; } export interface MatrixHistogramProps extends MatrixHistogramBasicProps { diff --git a/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.ts b/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.ts deleted file mode 100644 index 30d0673192af85..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mockAnomalies } from '../mock'; -import { cloneDeep } from 'lodash/fp'; -import { createExplorerLink } from './create_explorer_link'; - -describe('create_explorer_link', () => { - let anomalies = cloneDeep(mockAnomalies); - - beforeEach(() => { - anomalies = cloneDeep(mockAnomalies); - }); - - test('it returns expected link', () => { - const entities = createExplorerLink( - anomalies.anomalies[0], - new Date('1970').toISOString(), - new Date('3000').toISOString() - ); - expect(entities).toEqual( - "#/explorer?_g=(ml:(jobIds:!(job-1)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'1970-01-01T00:00:00.000Z',mode:absolute,to:'3000-01-01T00:00:00.000Z'))&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(),mlSelectLimit:(display:'10',val:10),mlShowCharts:!t)" - ); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.tsx new file mode 100644 index 00000000000000..d30cbd4b0e7f85 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.tsx @@ -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 React from 'react'; +import { render, act } from '@testing-library/react'; +import { mockAnomalies } from '../mock'; +import { cloneDeep } from 'lodash/fp'; +import { ExplorerLink } from './create_explorer_link'; +import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public/context'; +import { MlUrlGenerator } from '../../../../../../ml/public/ml_url_generator'; + +describe('create_explorer_link', () => { + let anomalies = cloneDeep(mockAnomalies); + + beforeEach(() => { + anomalies = cloneDeep(mockAnomalies); + }); + + test('it returns expected link', async () => { + const ml = { urlGenerator: new MlUrlGenerator({ appBasePath: '/app/ml', useHash: false }) }; + const http = { basePath: { get: jest.fn(() => {}) } }; + + await act(async () => { + const { findByText } = render( + + + + ); + + const url = (await findByText('Open in Anomaly Explorer')).getAttribute('href'); + + expect(url).toEqual( + "/app/ml/explorer?_g=(ml:(jobIds:!(job-1)),refreshInterval:(display:Off,pause:!t,value:0),time:(from:'1970-01-01T00:00:00.000Z',mode:absolute,to:'3000-01-01T00:00:00.000Z'))&_a=(explorer:(mlExplorerFilter:(),mlExplorerSwimlane:()))" + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.tsx b/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.tsx index 468bc962453f68..2ba47cfccb1a8e 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.tsx @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiLink } from '@elastic/eui'; import React from 'react'; +import { EuiLink } from '@elastic/eui'; import { Anomaly } from '../types'; import { useKibana } from '../../../lib/kibana'; +import { useMlHref } from '../../../../../../ml/public'; interface ExplorerLinkProps { score: Anomaly; @@ -22,26 +23,32 @@ export const ExplorerLink: React.FC = ({ endDate, linkName, }) => { - const { getUrlForApp } = useKibana().services.application; + const { + services: { ml, http }, + } = useKibana(); + + const explorerUrl = useMlHref(ml, http.basePath.get(), { + page: 'explorer', + pageState: { + jobIds: [score.jobId], + timeRange: { + from: new Date(startDate).toISOString(), + to: new Date(endDate).toISOString(), + mode: 'absolute', + }, + refreshInterval: { + pause: true, + value: 0, + display: 'Off', + }, + }, + }); + + if (!explorerUrl) return null; + return ( - + {linkName} ); }; - -export const createExplorerLink = (score: Anomaly, startDate: string, endDate: string): string => { - const startDateIso = new Date(startDate).toISOString(); - const endDateIso = new Date(endDate).toISOString(); - - const JOB_PREFIX = `#/explorer?_g=(ml:(jobIds:!(${score.jobId}))`; - const REFRESH_INTERVAL = `,refreshInterval:(display:Off,pause:!f,value:0),time:(from:'${startDateIso}',mode:absolute,to:'${endDateIso}'))`; - const INTERVAL_SELECTION = `&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(),mlSelectLimit:(display:'10',val:10),mlShowCharts:!t)`; - - return `${JOB_PREFIX}${REFRESH_INTERVAL}${INTERVAL_SELECTION}`; -}; diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts index 3ef6d78d651aab..df553f509a0ecf 100644 --- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts +++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts @@ -5,7 +5,7 @@ */ import deepEqual from 'fast-deep-equal'; -import { getOr, noop } from 'lodash/fp'; +import { getOr, isEmpty, noop } from 'lodash/fp'; import { useCallback, useEffect, useRef, useState } from 'react'; import { MatrixHistogramQueryProps } from '../../components/matrix_histogram/types'; @@ -43,11 +43,13 @@ export interface UseMatrixHistogramArgs { } export const useMatrixHistogram = ({ + docValueFields, endDate, errorMessage, filterQuery, histogramType, indexNames, + isPtrIncluded, stackByField, startDate, threshold, @@ -76,6 +78,8 @@ export const useMatrixHistogram = ({ }, stackByField, threshold, + ...(isPtrIncluded != null ? { isPtrIncluded } : {}), + ...(!isEmpty(docValueFields) ? { docValueFields } : {}), }); const [matrixHistogramResponse, setMatrixHistogramResponse] = useState({ @@ -167,13 +171,25 @@ export const useMatrixHistogram = ({ }, stackByField, threshold, + ...(isPtrIncluded != null ? { isPtrIncluded } : {}), + ...(!isEmpty(docValueFields) ? { docValueFields } : {}), }; if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, endDate, filterQuery, startDate, stackByField, histogramType, threshold]); + }, [ + indexNames, + endDate, + filterQuery, + startDate, + stackByField, + histogramType, + threshold, + isPtrIncluded, + docValueFields, + ]); useEffect(() => { if (!skip) { diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/eql_search_response.mock.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/eql_search_response.mock.ts index b3d3d39088d72c..3860368d5dcc88 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/eql_search_response.mock.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/eql_search_response.mock.ts @@ -87,6 +87,86 @@ export const getMockEqlResponse = (): EqlSearchStrategyResponse +> => ({ + id: 'some-id', + rawResponse: { + body: { + hits: { + events: [ + { + _index: 'index', + _id: '1', + _source: { + '@timestamp': 1601824614000, + }, + }, + { + _index: 'index', + _id: '2', + _source: { + '@timestamp': 1601826654368, + }, + }, + { + _index: 'index', + _id: '3', + _source: { + '@timestamp': 1601824014368, + }, + }, + { + _index: 'index', + _id: '4', + _source: { + '@timestamp': 1601824554368, + }, + }, + ], + total: { + value: 4, + relation: '', + }, + }, + is_partial: false, + is_running: false, + took: 300, + timed_out: false, + }, + headers: {}, + warnings: [], + meta: { + aborted: false, + attempts: 0, + context: null, + name: 'elasticsearch-js', + connection: {} as Connection, + request: { + params: { + body: JSON.stringify({ + filter: { + range: { + '@timestamp': { + gte: '2020-10-07T00:46:12.414Z', + lte: '2020-10-07T01:46:12.414Z', + format: 'strict_date_optional_time', + }, + }, + }, + }), + method: 'GET', + path: '/_eql/search/', + querystring: 'some query string', + }, + options: {}, + id: '', + }, + }, + statusCode: 200, + }, +}); + export const getMockEqlSequenceResponse = (): EqlSearchStrategyResponse< EqlSearchResponse > => ({ diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts index 6ba2eaa3d3c30c..c3cd329ca62f21 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts @@ -19,7 +19,11 @@ import { formatInspect, getEventsToBucket, } from './helpers'; -import { getMockEqlResponse, getMockEqlSequenceResponse } from './eql_search_response.mock'; +import { + getMockEndgameEqlResponse, + getMockEqlResponse, + getMockEqlSequenceResponse, +} from './eql_search_response.mock'; describe('eql/helpers', () => { describe('calculateBucketForHour', () => { @@ -145,6 +149,62 @@ describe('eql/helpers', () => { describe('getEqlAggsData', () => { describe('non-sequence', () => { + // NOTE: We previously expected @timestamp to be a string, however, + // date can also be a number (like for endgame-*) + test('it works when @timestamp is a number', () => { + const mockResponse = getMockEndgameEqlResponse(); + + const aggs = getEqlAggsData( + mockResponse, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + false + ); + + const date1 = moment(aggs.data[0].x); + const date2 = moment(aggs.data[1].x); + // This will be in ms + const diff = date1.diff(date2); + + expect(diff).toEqual(120000); + expect(aggs.data).toHaveLength(31); + expect(aggs.data).toEqual([ + { g: 'hits', x: 1601827200368, y: 0 }, + { g: 'hits', x: 1601827080368, y: 0 }, + { g: 'hits', x: 1601826960368, y: 0 }, + { g: 'hits', x: 1601826840368, y: 0 }, + { g: 'hits', x: 1601826720368, y: 0 }, + { g: 'hits', x: 1601826600368, y: 1 }, + { g: 'hits', x: 1601826480368, y: 0 }, + { g: 'hits', x: 1601826360368, y: 0 }, + { g: 'hits', x: 1601826240368, y: 0 }, + { g: 'hits', x: 1601826120368, y: 0 }, + { g: 'hits', x: 1601826000368, y: 0 }, + { g: 'hits', x: 1601825880368, y: 0 }, + { g: 'hits', x: 1601825760368, y: 0 }, + { g: 'hits', x: 1601825640368, y: 0 }, + { g: 'hits', x: 1601825520368, y: 0 }, + { g: 'hits', x: 1601825400368, y: 0 }, + { g: 'hits', x: 1601825280368, y: 0 }, + { g: 'hits', x: 1601825160368, y: 0 }, + { g: 'hits', x: 1601825040368, y: 0 }, + { g: 'hits', x: 1601824920368, y: 0 }, + { g: 'hits', x: 1601824800368, y: 0 }, + { g: 'hits', x: 1601824680368, y: 0 }, + { g: 'hits', x: 1601824560368, y: 2 }, + { g: 'hits', x: 1601824440368, y: 0 }, + { g: 'hits', x: 1601824320368, y: 0 }, + { g: 'hits', x: 1601824200368, y: 0 }, + { g: 'hits', x: 1601824080368, y: 0 }, + { g: 'hits', x: 1601823960368, y: 1 }, + { g: 'hits', x: 1601823840368, y: 0 }, + { g: 'hits', x: 1601823720368, y: 0 }, + { g: 'hits', x: 1601823600368, y: 0 }, + ]); + }); + test('it returns results bucketed into 2 min intervals when range is "h"', () => { const mockResponse = getMockEqlResponse(); diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts index d1a29987c8ced4..b50881ba17f98b 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts @@ -116,8 +116,8 @@ export const getEqlAggsData = ( if (timestamp == null) { return acc; } - - const eventTimestamp = Date.parse(timestamp); + const eventDate = new Date(timestamp).toISOString(); + const eventTimestamp = Date.parse(eventDate); const bucket = range === 'h' ? calculateBucketForHour(eventTimestamp, relativeNow) diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/types.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/types.ts index 5bd51da28badc5..f3bb09c80138ea 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/types.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/types.ts @@ -25,5 +25,5 @@ export interface EqlPreviewResponse { } export interface Source { - '@timestamp': string; + '@timestamp': string | number; } diff --git a/x-pack/plugins/security_solution/public/common/utils/clone_http_fetch_query.ts b/x-pack/plugins/security_solution/public/common/utils/clone_http_fetch_query.ts index 90b81df8bc21e6..809542c6b2faa6 100644 --- a/x-pack/plugins/security_solution/public/common/utils/clone_http_fetch_query.ts +++ b/x-pack/plugins/security_solution/public/common/utils/clone_http_fetch_query.ts @@ -11,7 +11,7 @@ export function cloneHttpFetchQuery(query: Immutable): HttpFetch const clone: HttpFetchQuery = {}; for (const [key, value] of Object.entries(query)) { if (Array.isArray(value)) { - clone[key] = [...value]; + clone[key] = [...value] as string[] | number[] | boolean[]; } else { // Array.isArray is not removing ImmutableArray from the union. clone[key] = value as string | number | boolean; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx index 0211788509db3b..b653fc05850af1 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx @@ -46,9 +46,6 @@ export const RuleActionsField: React.FC = ({ field, messageVariables }) = const { http, triggersActionsUi: { actionTypeRegistry }, - notifications, - docLinks, - application: { capabilities }, } = useKibana().services; const actions: AlertAction[] = useMemo( @@ -119,18 +116,14 @@ export const RuleActionsField: React.FC = ({ field, messageVariables }) = ) : null} ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx index b631d37140a38f..564b382b7b29f9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx @@ -40,7 +40,7 @@ describe('AllRulesTable Columns', () => { test('duplicate rule onClick should call refetch after the rule is duplicated', async () => { (duplicateRulesAction as jest.Mock).mockImplementation( () => - new Promise((resolve) => + new Promise((resolve) => setTimeout(() => { results.push('duplicateRulesAction'); resolve(); @@ -62,7 +62,7 @@ describe('AllRulesTable Columns', () => { test('delete rule onClick should call refetch after the rule is deleted', async () => { (deleteRulesAction as jest.Mock).mockImplementation( () => - new Promise((resolve) => + new Promise((resolve) => setTimeout(() => { results.push('deleteRulesAction'); resolve(); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index 02d41e7dea1a74..967a0d3e25b20e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -14,6 +14,8 @@ import { EuiListGroupItem, EuiIcon, EuiText, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -139,7 +141,31 @@ export const EndpointDetails = memo( > {details.Endpoint.policy.applied.name} - {isPolicyOutOfDate(details.Endpoint.policy.applied, policyInfo) && } + + {details.Endpoint.policy.applied.endpoint_policy_version && ( + + + + + + )} + {isPolicyOutOfDate(details.Endpoint.policy.applied, policyInfo) && ( + + + + )} + ), }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index edc15e22a699ee..22b9950b1a2b33 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -14,6 +14,7 @@ import { EuiText, EuiSpacer, EuiEmptyPrompt, + EuiToolTip, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -84,13 +85,20 @@ export const EndpointDetailsFlyout = memo(() => { size="s" > - - {loading ? ( - - ) : ( -

{details?.host?.hostname}

- )} -
+ {loading ? ( + + ) : ( + + +

+ {details?.host?.hostname} +

+
+
+ )}
{details === undefined ? ( <> diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 69889d3d0a881d..487f5ddab5504e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -220,6 +220,7 @@ describe('when on the list page', () => { HostInfo['metadata']['Endpoint']['policy']['applied']['status'] > = []; let firstPolicyID: string; + let firstPolicyRev: number; beforeEach(() => { reactTestingLibrary.act(() => { const mockedEndpointData = mockEndpointResultList({ total: 4 }); @@ -227,6 +228,7 @@ describe('when on the list page', () => { const queryStrategyVersion = mockedEndpointData.query_strategy_version; firstPolicyID = hostListData[0].metadata.Endpoint.policy.applied.id; + firstPolicyRev = hostListData[0].metadata.Endpoint.policy.applied.endpoint_policy_version; // add ability to change (immutable) policy type DeepMutable = { -readonly [P in keyof T]: DeepMutable }; @@ -402,6 +404,16 @@ describe('when on the list page', () => { }); }); }); + + it('should show revision number', async () => { + const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedEndpointList'); + }); + const firstPolicyRevElement = (await renderResult.findAllByTestId('policyListRevNo'))[0]; + expect(firstPolicyRevElement).not.toBeNull(); + expect(firstPolicyRevElement.textContent).toEqual(`rev. ${firstPolicyRev}`); + }); }); }); @@ -586,6 +598,15 @@ describe('when on the list page', () => { ); }); + it('should display policy revision number', async () => { + const renderResult = await renderAndWaitForData(); + const policyDetailsRevElement = await renderResult.findByTestId('policyDetailsRevNo'); + expect(policyDetailsRevElement).not.toBeNull(); + expect(policyDetailsRevElement.textContent).toEqual( + `rev. ${hostDetails.metadata.Endpoint.policy.applied.endpoint_policy_version}` + ); + }); + it('should update the URL when policy name link is clicked', async () => { const renderResult = await renderAndWaitForData(); const policyDetailsLink = await renderResult.findByTestId('policyDetailsValue'); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index a759c9de414156..eaa748d781b9b5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -219,6 +219,8 @@ export const EndpointList = () => { const NOOP = useCallback(() => {}, []); + const PAD_LEFT: React.CSSProperties = { paddingLeft: '6px' }; + const handleDeployEndpointsClick = useNavigateToAppEventHandler( 'fleet', { @@ -337,8 +339,22 @@ export const EndpointList = () => { {policy.name} + {policy.endpoint_policy_version && ( + + + + )} {isPolicyOutOfDate(policy, item.policy_info) && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts index bb868418e7f0df..5265cee2e59729 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts @@ -136,8 +136,8 @@ export const getCurrentResourceError = ( }; export const isOutdatedResourceState = ( - state: AsyncResourceState, - isFresh: (data: Data) => boolean + state: Immutable>, + isFresh: (data: Immutable) => boolean ): boolean => isUninitialisedResourceState(state) || (isLoadedResourceState(state) && !isFresh(state.data)) || diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index 589fbac03a7e20..e7c21e1a997645 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -35,7 +35,7 @@ export const needsRefreshOfListData = (state: Immutable { return ( data.pageIndex === location.page_index && diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/histogram.ts b/x-pack/plugins/security_solution/public/network/containers/network_dns/histogram.ts deleted file mode 100644 index dce0c3bd2b30d4..00000000000000 --- a/x-pack/plugins/security_solution/public/network/containers/network_dns/histogram.ts +++ /dev/null @@ -1,65 +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 { connect } from 'react-redux'; -import { compose } from 'redux'; -import { DocumentNode } from 'graphql'; -import { ScaleType } from '@elastic/charts'; - -import { MatrixHistogram } from '../../../common/components/matrix_histogram'; -import { - MatrixHistogramOption, - GetSubTitle, -} from '../../../common/components/matrix_histogram/types'; -import { UpdateDateRange } from '../../../common/components/charts/common'; -import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; -import { withKibana } from '../../../common/lib/kibana'; -import { QueryTemplatePaginatedProps } from '../../../common/containers/query_template_paginated'; -import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../../../common/store/constants'; -import { networkModel, networkSelectors } from '../../store'; -import { State, inputsSelectors } from '../../../common/store'; - -export const HISTOGRAM_ID = 'networkDnsHistogramQuery'; - -interface DnsHistogramOwnProps extends QueryTemplatePaginatedProps { - dataKey: string | string[]; - defaultStackByOption: MatrixHistogramOption; - errorMessage: string; - isDnsHistogram?: boolean; - query: DocumentNode; - scaleType: ScaleType; - setQuery: GlobalTimeArgs['setQuery']; - showLegend?: boolean; - stackByOptions: MatrixHistogramOption[]; - subtitle?: string | GetSubTitle; - title: string; - type: networkModel.NetworkType; - updateDateRange: UpdateDateRange; - yTickFormatter?: (value: number) => string; -} - -const makeMapHistogramStateToProps = () => { - const getNetworkDnsSelector = networkSelectors.dnsSelector(); - const getQuery = inputsSelectors.globalQueryByIdSelector(); - const mapStateToProps = (state: State, { id = HISTOGRAM_ID }: DnsHistogramOwnProps) => { - const { isInspected } = getQuery(state, id); - return { - ...getNetworkDnsSelector(state), - activePage: DEFAULT_TABLE_ACTIVE_PAGE, - limit: DEFAULT_TABLE_LIMIT, - isInspected, - id, - }; - }; - - return mapStateToProps; -}; - -export const NetworkDnsHistogramQuery = compose>( - connect(makeMapHistogramStateToProps), - withKibana -)(MatrixHistogram); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx index 108bfa0c9df698..aab90702de337a 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx @@ -13,23 +13,23 @@ import { inputsModel } from '../../../common/store'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { useKibana } from '../../../common/lib/kibana'; import { createFilter } from '../../../common/containers/helpers'; -import { NetworkDnsEdges, PageInfoPaginated } from '../../../../common/search_strategy'; import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; import { networkModel, networkSelectors } from '../../store'; import { + DocValueFields, NetworkQueries, NetworkDnsRequestOptions, NetworkDnsStrategyResponse, MatrixOverOrdinalHistogramData, -} from '../../../../common/search_strategy/security_solution/network'; + NetworkDnsEdges, + PageInfoPaginated, +} from '../../../../common/search_strategy'; import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; import { AbortError } from '../../../../../../../src/plugins/kibana_utils/common'; import * as i18n from './translations'; import { getInspectResponse } from '../../../helpers'; import { InspectResponse } from '../../../types'; -export * from './histogram'; - const ID = 'networkDnsQuery'; export interface NetworkDnsArgs { @@ -47,6 +47,7 @@ export interface NetworkDnsArgs { interface UseNetworkDns { id?: string; + docValueFields: DocValueFields[]; indexNames: string[]; type: networkModel.NetworkType; filterQuery?: ESTermQuery | string; @@ -56,6 +57,7 @@ interface UseNetworkDns { } export const useNetworkDns = ({ + docValueFields, endDate, filterQuery, indexNames, @@ -74,6 +76,7 @@ export const useNetworkDns = ({ !skip ? { defaultIndex: indexNames, + docValueFields: docValueFields ?? [], factoryQueryType: NetworkQueries.dns, filterQuery: createFilter(filterQuery), isPtrIncluded, @@ -190,6 +193,7 @@ export const useNetworkDns = ({ const myRequest = { ...(prevRequest ?? {}), defaultIndex: indexNames, + docValueFields: docValueFields ?? [], isPtrIncluded, factoryQueryType: NetworkQueries.dns, filterQuery: createFilter(filterQuery), @@ -206,7 +210,18 @@ export const useNetworkDns = ({ } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip, isPtrIncluded]); + }, [ + activePage, + indexNames, + endDate, + filterQuery, + limit, + startDate, + sort, + skip, + isPtrIncluded, + docValueFields, + ]); useEffect(() => { networkDnsSearch(networkDnsRequest); diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx index a8bae2509e0d6e..8d850a926f0937 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useCallback, useMemo } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkDnsTable } from '../../components/network_dns_table'; -import { useNetworkDns, HISTOGRAM_ID } from '../../containers/network_dns'; +import { useNetworkDns } from '../../containers/network_dns'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkComponentQueryProps } from './types'; @@ -20,6 +20,10 @@ import { import * as i18n from '../translations'; import { MatrixHistogram } from '../../../common/components/matrix_histogram'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; +import { networkSelectors } from '../../store'; +import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; + +const HISTOGRAM_ID = 'networkDnsHistogramQuery'; const NetworkDnsTableManage = manageQuery(NetworkDnsTable); @@ -43,6 +47,7 @@ export const histogramConfigs: Omit = { const DnsQueryTabBodyComponent: React.FC = ({ deleteQuery, + docValueFields, endDate, filterQuery, indexNames, @@ -51,6 +56,9 @@ const DnsQueryTabBodyComponent: React.FC = ({ setQuery, type, }) => { + const getNetworkDnsSelector = networkSelectors.dnsSelector(); + const { isPtrIncluded } = useShallowEqualSelector(getNetworkDnsSelector); + useEffect(() => { return () => { if (deleteQuery) { @@ -63,6 +71,7 @@ const DnsQueryTabBodyComponent: React.FC = ({ loading, { totalCount, networkDns, pageInfo, loadPage, id, inspect, isInspected, refetch }, ] = useNetworkDns({ + docValueFields: docValueFields ?? [], endDate, filterQuery, indexNames, @@ -87,9 +96,11 @@ const DnsQueryTabBodyComponent: React.FC = ({ return ( <> ( ({ networkPagePath, + docValueFields, type, to, filterQuery, @@ -107,7 +108,7 @@ export const NetworkRoutes = React.memo( return ( - + <> diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/types.ts b/x-pack/plugins/security_solution/public/network/pages/navigation/types.ts index ed04fd01a7b891..ef8cc4079e9a5d 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/types.ts @@ -14,6 +14,7 @@ import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { SetAbsoluteRangeDatePicker } from '../types'; import { NarrowDateRange } from '../../../common/components/ml/types'; +import { DocValueFields } from '../../../common/containers/source'; interface QueryTabBodyProps extends Pick { skip: boolean; @@ -25,7 +26,9 @@ interface QueryTabBodyProps extends Pick( { + await new Promise((resolve) => { setTimeout(() => { this.forceAutoSizerOpen(); this.wrapper.update(); diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index 35cf2c36d6627f..4e49617b6c8b18 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -66,7 +66,7 @@ describe('useCamera on an unpainted element', () => { while (timeoutCount < 10) { timeoutCount++; yield mapper(); - await new Promise((resolve) => { + await new Promise((resolve) => { setTimeout(() => { wrapper.update(); resolve(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx index 1106ee63a03cba..ef36d78e51a7c1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx @@ -23,6 +23,7 @@ export const Duration = React.memo<{ }>(({ contextId, eventId, fieldName, value }) => ( (({ contextId, eventId, fieldName, value }) => ( { [ { id: `id-exists`, - name, + name: 'name', enabled: true, excluded: false, kqlQuery: '', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts index b5d657fe55a1f7..42a69d7b1e9648 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts @@ -7,16 +7,18 @@ import { IRouter } from 'kibana/server'; import { EndpointAppContext } from '../types'; import { - validateTree, + validateTreeEntityID, validateEvents, validateChildren, validateAncestry, validateAlerts, validateEntities, + validateTree, } from '../../../common/endpoint/schema/resolver'; import { handleChildren } from './resolver/children'; import { handleAncestry } from './resolver/ancestry'; -import { handleTree } from './resolver/tree'; +import { handleTree as handleTreeEntityID } from './resolver/tree'; +import { handleTree } from './resolver/tree/handler'; import { handleAlerts } from './resolver/alerts'; import { handleEntities } from './resolver/entity'; import { handleEvents } from './resolver/events'; @@ -24,6 +26,15 @@ import { handleEvents } from './resolver/events'; export function registerResolverRoutes(router: IRouter, endpointAppContext: EndpointAppContext) { const log = endpointAppContext.logFactory.get('resolver'); + router.post( + { + path: '/api/endpoint/resolver/tree', + validate: validateTree, + options: { authRequired: true }, + }, + handleTree(log) + ); + router.post( { path: '/api/endpoint/resolver/events', @@ -33,6 +44,9 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp handleEvents(log) ); + /** + * @deprecated will be removed because it is not used + */ router.post( { path: '/api/endpoint/resolver/{id}/alerts', @@ -42,6 +56,9 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp handleAlerts(log, endpointAppContext) ); + /** + * @deprecated use the /resolver/tree api instead + */ router.get( { path: '/api/endpoint/resolver/{id}/children', @@ -51,6 +68,9 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp handleChildren(log, endpointAppContext) ); + /** + * @deprecated use the /resolver/tree api instead + */ router.get( { path: '/api/endpoint/resolver/{id}/ancestry', @@ -60,13 +80,16 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp handleAncestry(log, endpointAppContext) ); + /** + * @deprecated use the /resolver/tree api instead + */ router.get( { path: '/api/endpoint/resolver/{id}', - validate: validateTree, + validate: validateTreeEntityID, options: { authRequired: true }, }, - handleTree(log, endpointAppContext) + handleTreeEntityID(log, endpointAppContext) ); /** diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts index 02cddc3ddcf6ef..08cb9b56bf64c1 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts @@ -7,14 +7,17 @@ import { RequestHandler, Logger } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; import { eventsIndexPattern, alertsIndexPattern } from '../../../../common/endpoint/constants'; -import { validateTree } from '../../../../common/endpoint/schema/resolver'; +import { validateTreeEntityID } from '../../../../common/endpoint/schema/resolver'; import { Fetcher } from './utils/fetch'; import { EndpointAppContext } from '../../types'; export function handleTree( log: Logger, endpointAppContext: EndpointAppContext -): RequestHandler, TypeOf> { +): RequestHandler< + TypeOf, + TypeOf +> { return async (context, req, res) => { try { const client = context.core.elasticsearch.legacy.client; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/handler.ts new file mode 100644 index 00000000000000..8c62cf87629814 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/handler.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 { RequestHandler, Logger } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { validateTree } from '../../../../../common/endpoint/schema/resolver'; +import { Fetcher } from './utils/fetch'; + +export function handleTree( + log: Logger +): RequestHandler> { + return async (context, req, res) => { + try { + const client = context.core.elasticsearch.client; + const fetcher = new Fetcher(client); + const body = await fetcher.tree(req.body); + return res.ok({ + body, + }); + } catch (err) { + log.warn(err); + return res.internalError({ body: 'Error retrieving tree.' }); + } + }; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts new file mode 100644 index 00000000000000..405429cc241915 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SearchResponse } from 'elasticsearch'; +import { ApiResponse } from '@elastic/elasticsearch'; +import { IScopedClusterClient } from 'src/core/server'; +import { FieldsObject } from '../../../../../../common/endpoint/types'; +import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; +import { NodeID, Schema, Timerange, docValueFields } from '../utils/index'; + +interface DescendantsParams { + schema: Schema; + indexPatterns: string | string[]; + timerange: Timerange; +} + +/** + * Builds a query for retrieving descendants of a node. + */ +export class DescendantsQuery { + private readonly schema: Schema; + private readonly indexPatterns: string | string[]; + private readonly timerange: Timerange; + private readonly docValueFields: JsonValue[]; + constructor({ schema, indexPatterns, timerange }: DescendantsParams) { + this.docValueFields = docValueFields(schema); + this.schema = schema; + this.indexPatterns = indexPatterns; + this.timerange = timerange; + } + + private query(nodes: NodeID[], size: number): JsonObject { + return { + _source: false, + docvalue_fields: this.docValueFields, + size, + collapse: { + field: this.schema.id, + }, + sort: [{ '@timestamp': 'asc' }], + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: this.timerange.from, + lte: this.timerange.to, + format: 'strict_date_optional_time', + }, + }, + }, + { + terms: { [this.schema.parent]: nodes }, + }, + { + exists: { + field: this.schema.id, + }, + }, + { + exists: { + field: this.schema.parent, + }, + }, + { + term: { 'event.category': 'process' }, + }, + { + term: { 'event.kind': 'event' }, + }, + ], + }, + }, + }; + } + + private queryWithAncestryArray(nodes: NodeID[], ancestryField: string, size: number): JsonObject { + return { + _source: false, + docvalue_fields: this.docValueFields, + size, + collapse: { + field: this.schema.id, + }, + sort: [ + { + _script: { + type: 'number', + script: { + /** + * This script is used to sort the returned documents in a breadth first order so that we return all of + * a single level of nodes before returning the next level of nodes. This is needed because using the + * ancestry array could result in the search going deep before going wide depending on when the nodes + * spawned their children. If a node spawns a child before it's sibling is spawned then the child would + * be found before the sibling because by default the sort was on timestamp ascending. + */ + source: ` + Map ancestryToIndex = [:]; + List sourceAncestryArray = params._source.${ancestryField}; + int length = sourceAncestryArray.length; + for (int i = 0; i < length; i++) { + ancestryToIndex[sourceAncestryArray[i]] = i; + } + for (String id : params.ids) { + def index = ancestryToIndex[id]; + if (index != null) { + return index; + } + } + return -1; + `, + params: { + ids: nodes, + }, + }, + }, + }, + { '@timestamp': 'asc' }, + ], + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: this.timerange.from, + lte: this.timerange.to, + format: 'strict_date_optional_time', + }, + }, + }, + { + terms: { + [ancestryField]: nodes, + }, + }, + { + exists: { + field: this.schema.id, + }, + }, + { + exists: { + field: this.schema.parent, + }, + }, + { + exists: { + field: ancestryField, + }, + }, + { + term: { 'event.category': 'process' }, + }, + { + term: { 'event.kind': 'event' }, + }, + ], + }, + }, + }; + } + + /** + * Searches for descendant nodes matching the specified IDs. + * + * @param client for making requests to Elasticsearch + * @param nodes the unique IDs to search for in Elasticsearch + * @param limit the upper limit of documents to returned + */ + async search( + client: IScopedClusterClient, + nodes: NodeID[], + limit: number + ): Promise { + if (nodes.length <= 0) { + return []; + } + + let response: ApiResponse>; + if (this.schema.ancestry) { + response = await client.asCurrentUser.search({ + body: this.queryWithAncestryArray(nodes, this.schema.ancestry, limit), + index: this.indexPatterns, + }); + } else { + response = await client.asCurrentUser.search({ + body: this.query(nodes, limit), + index: this.indexPatterns, + }); + } + + /** + * The returned values will look like: + * [ + * { 'schema_id_value': , 'schema_parent_value': } + * ] + * + * So the schema fields are flattened ('process.parent.entity_id') + */ + return response.body.hits.hits.map((hit) => hit.fields); + } +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts new file mode 100644 index 00000000000000..606a4538ba88cb --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.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 { SearchResponse } from 'elasticsearch'; +import { ApiResponse } from '@elastic/elasticsearch'; +import { IScopedClusterClient } from 'src/core/server'; +import { FieldsObject } from '../../../../../../common/endpoint/types'; +import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; +import { NodeID, Schema, Timerange, docValueFields } from '../utils/index'; + +interface LifecycleParams { + schema: Schema; + indexPatterns: string | string[]; + timerange: Timerange; +} + +/** + * Builds a query for retrieving descendants of a node. + */ +export class LifecycleQuery { + private readonly schema: Schema; + private readonly indexPatterns: string | string[]; + private readonly timerange: Timerange; + private readonly docValueFields: JsonValue[]; + constructor({ schema, indexPatterns, timerange }: LifecycleParams) { + this.docValueFields = docValueFields(schema); + this.schema = schema; + this.indexPatterns = indexPatterns; + this.timerange = timerange; + } + + private query(nodes: NodeID[]): JsonObject { + return { + _source: false, + docvalue_fields: this.docValueFields, + size: nodes.length, + collapse: { + field: this.schema.id, + }, + sort: [{ '@timestamp': 'asc' }], + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: this.timerange.from, + lte: this.timerange.to, + format: 'strict_date_optional_time', + }, + }, + }, + { + terms: { [this.schema.id]: nodes }, + }, + { + exists: { + field: this.schema.id, + }, + }, + { + term: { 'event.category': 'process' }, + }, + { + term: { 'event.kind': 'event' }, + }, + ], + }, + }, + }; + } + + /** + * Searches for lifecycle events matching the specified node IDs. + * + * @param client for making requests to Elasticsearch + * @param nodes the unique IDs to search for in Elasticsearch + */ + async search(client: IScopedClusterClient, nodes: NodeID[]): Promise { + if (nodes.length <= 0) { + return []; + } + + const response: ApiResponse> = await client.asCurrentUser.search({ + body: this.query(nodes), + index: this.indexPatterns, + }); + + /** + * The returned values will look like: + * [ + * { 'schema_id_value': , 'schema_parent_value': } + * ] + * + * So the schema fields are flattened ('process.parent.entity_id') + */ + return response.body.hits.hits.map((hit) => hit.fields); + } +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts new file mode 100644 index 00000000000000..33dcdce8987f5d --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SearchResponse } from 'elasticsearch'; +import { ApiResponse } from '@elastic/elasticsearch'; +import { IScopedClusterClient } from 'src/core/server'; +import { JsonObject } from '../../../../../../../../../src/plugins/kibana_utils/common'; +import { EventStats } from '../../../../../../common/endpoint/types'; +import { NodeID, Schema, Timerange } from '../utils/index'; + +interface AggBucket { + key: string; + doc_count: number; +} + +interface CategoriesAgg extends AggBucket { + /** + * The reason categories is optional here is because if no data was returned in the query the categories aggregation + * will not be defined on the response (because it's a sub aggregation). + */ + categories?: { + buckets?: AggBucket[]; + }; +} + +interface StatsParams { + schema: Schema; + indexPatterns: string | string[]; + timerange: Timerange; +} + +/** + * Builds a query for retrieving descendants of a node. + */ +export class StatsQuery { + private readonly schema: Schema; + private readonly indexPatterns: string | string[]; + private readonly timerange: Timerange; + constructor({ schema, indexPatterns, timerange }: StatsParams) { + this.schema = schema; + this.indexPatterns = indexPatterns; + this.timerange = timerange; + } + + private query(nodes: NodeID[]): JsonObject { + return { + size: 0, + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: this.timerange.from, + lte: this.timerange.to, + format: 'strict_date_optional_time', + }, + }, + }, + { + terms: { [this.schema.id]: nodes }, + }, + { + term: { 'event.kind': 'event' }, + }, + { + bool: { + must_not: { + term: { + 'event.category': 'process', + }, + }, + }, + }, + ], + }, + }, + aggs: { + ids: { + terms: { field: this.schema.id, size: nodes.length }, + aggs: { + categories: { + terms: { field: 'event.category', size: 1000 }, + }, + }, + }, + }, + }; + } + + private static getEventStats(catAgg: CategoriesAgg): EventStats { + const total = catAgg.doc_count; + if (!catAgg.categories?.buckets) { + return { + total, + byCategory: {}, + }; + } + + const byCategory: Record = catAgg.categories.buckets.reduce( + (cummulative: Record, bucket: AggBucket) => ({ + ...cummulative, + [bucket.key]: bucket.doc_count, + }), + {} + ); + return { + total, + byCategory, + }; + } + + /** + * Returns the related event statistics for a set of nodes. + * @param client used to make requests to Elasticsearch + * @param nodes an array of unique IDs representing nodes in a resolver graph + */ + async search(client: IScopedClusterClient, nodes: NodeID[]): Promise> { + if (nodes.length <= 0) { + return {}; + } + + // leaving unknown here because we don't actually need the hits part of the body + const response: ApiResponse> = await client.asCurrentUser.search({ + body: this.query(nodes), + index: this.indexPatterns, + }); + + return response.body.aggregations?.ids?.buckets.reduce( + (cummulative: Record, bucket: CategoriesAgg) => ({ + ...cummulative, + [bucket.key]: StatsQuery.getEventStats(bucket), + }), + {} + ); + } +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts new file mode 100644 index 00000000000000..8105f1125d01db --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts @@ -0,0 +1,707 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + Fetcher, + getAncestryAsArray, + getIDField, + getLeafNodes, + getNameField, + getParentField, + TreeOptions, +} from './fetch'; +import { LifecycleQuery } from '../queries/lifecycle'; +import { DescendantsQuery } from '../queries/descendants'; +import { StatsQuery } from '../queries/stats'; +import { IScopedClusterClient } from 'src/core/server'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { FieldsObject, ResolverNode } from '../../../../../../common/endpoint/types'; +import { Schema } from './index'; + +jest.mock('../queries/descendants'); +jest.mock('../queries/lifecycle'); +jest.mock('../queries/stats'); + +function formatResponse(results: FieldsObject[], schema: Schema): ResolverNode[] { + return results.map((node) => { + return { + id: getIDField(node, schema) ?? '', + parent: getParentField(node, schema), + name: getNameField(node, schema), + data: node, + stats: { + total: 0, + byCategory: {}, + }, + }; + }); +} + +describe('fetcher test', () => { + const schemaIDParent = { + id: 'id', + parent: 'parent', + }; + + const schemaIDParentAncestry = { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }; + + const schemaIDParentName = { + id: 'id', + parent: 'parent', + name: 'name', + }; + + let client: jest.Mocked; + beforeAll(() => { + StatsQuery.prototype.search = jest.fn().mockImplementation(async () => { + return {}; + }); + }); + beforeEach(() => { + client = elasticsearchServiceMock.createScopedClusterClient(); + }); + + describe('descendants', () => { + it('correctly exists loop when the search returns no results', async () => { + DescendantsQuery.prototype.search = jest.fn().mockImplementationOnce(async () => { + return []; + }); + const options: TreeOptions = { + descendantLevels: 1, + descendants: 5, + ancestors: 0, + timerange: { + from: '', + to: '', + }, + schema: { + id: '', + parent: '', + }, + indexPatterns: [''], + nodes: ['a'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual([]); + }); + + it('exists the loop when the options specify no descendants', async () => { + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 0, + timerange: { + from: '', + to: '', + }, + schema: { + id: '', + parent: '', + }, + indexPatterns: [''], + nodes: ['a'], + }; + + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual([]); + }); + + it('returns the correct results without the ancestry defined', async () => { + /** + . + └── 0 + ├── 1 + │ └── 2 + └── 3 + ├── 4 + └── 5 + */ + const level1 = [ + { + id: '1', + parent: '0', + }, + { + id: '3', + parent: '0', + }, + ]; + const level2 = [ + { + id: '2', + parent: '1', + }, + + { + id: '4', + parent: '3', + }, + { + id: '5', + parent: '3', + }, + ]; + DescendantsQuery.prototype.search = jest + .fn() + .mockImplementationOnce(async () => { + return level1; + }) + .mockImplementationOnce(async () => { + return level2; + }); + const options: TreeOptions = { + descendantLevels: 2, + descendants: 5, + ancestors: 0, + timerange: { + from: '', + to: '', + }, + schema: schemaIDParent, + indexPatterns: [''], + nodes: ['0'], + }; + + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual( + formatResponse([...level1, ...level2], schemaIDParent) + ); + }); + }); + + describe('ancestors', () => { + it('correctly exits loop when the search returns no results', async () => { + LifecycleQuery.prototype.search = jest.fn().mockImplementationOnce(async () => { + return []; + }); + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 5, + timerange: { + from: '', + to: '', + }, + schema: { + id: '', + parent: '', + }, + indexPatterns: [''], + nodes: ['a'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual([]); + }); + + it('correctly exits loop when the options specify no ancestors', async () => { + LifecycleQuery.prototype.search = jest.fn().mockImplementationOnce(async () => { + throw new Error('should not have called this'); + }); + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 0, + timerange: { + from: '', + to: '', + }, + schema: { + id: '', + parent: '', + }, + indexPatterns: [''], + nodes: ['a'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual([]); + }); + + it('correctly returns the ancestors when the number of levels has been reached', async () => { + LifecycleQuery.prototype.search = jest + .fn() + .mockImplementationOnce(async () => { + return [ + { + id: '3', + parent: '2', + }, + ]; + }) + .mockImplementationOnce(async () => { + return [ + { + id: '2', + parent: '1', + }, + ]; + }); + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 2, + timerange: { + from: '', + to: '', + }, + schema: schemaIDParent, + indexPatterns: [''], + nodes: ['3'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual( + formatResponse( + [ + { id: '3', parent: '2' }, + { id: '2', parent: '1' }, + ], + schemaIDParent + ) + ); + }); + + it('correctly adds name field to response', async () => { + LifecycleQuery.prototype.search = jest + .fn() + .mockImplementationOnce(async () => { + return [ + { + id: '3', + parent: '2', + }, + ]; + }) + .mockImplementationOnce(async () => { + return [ + { + id: '2', + parent: '1', + }, + ]; + }); + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 2, + timerange: { + from: '', + to: '', + }, + schema: schemaIDParentName, + indexPatterns: [''], + nodes: ['3'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual( + formatResponse( + [ + { id: '3', parent: '2' }, + { id: '2', parent: '1' }, + ], + schemaIDParentName + ) + ); + }); + + it('correctly returns the ancestors with ancestry arrays', async () => { + const node3 = { + ancestry: ['2', '1'], + id: '3', + parent: '2', + }; + + const node1 = { + ancestry: ['0'], + id: '1', + parent: '0', + }; + + const node2 = { + ancestry: ['1', '0'], + id: '2', + parent: '1', + }; + LifecycleQuery.prototype.search = jest + .fn() + .mockImplementationOnce(async () => { + return [node3]; + }) + .mockImplementationOnce(async () => { + return [node1, node2]; + }); + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 3, + timerange: { + from: '', + to: '', + }, + schema: schemaIDParentAncestry, + indexPatterns: [''], + nodes: ['3'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual( + formatResponse([node3, node1, node2], schemaIDParentAncestry) + ); + }); + }); + + describe('retrieving leaf nodes', () => { + it('correctly identifies the leaf nodes in a response without the ancestry field', () => { + /** + . + └── 0 + ├── 1 + ├── 2 + └── 3 + */ + const results = [ + { + id: '1', + parent: '0', + }, + { + id: '2', + parent: '0', + }, + { + id: '3', + parent: '0', + }, + ]; + const leaves = getLeafNodes(results, ['0'], { id: 'id', parent: 'parent' }); + expect(leaves).toStrictEqual(['1', '2', '3']); + }); + + it('correctly ignores nodes without the proper fields', () => { + /** + . + └── 0 + ├── 1 + ├── 2 + */ + const results = [ + { + id: '1', + parent: '0', + }, + { + id: '2', + parent: '0', + }, + { + idNotReal: '3', + parentNotReal: '0', + }, + ]; + const leaves = getLeafNodes(results, ['0'], { id: 'id', parent: 'parent' }); + expect(leaves).toStrictEqual(['1', '2']); + }); + + it('returns an empty response when the proper fields are not defined', () => { + const results = [ + { + id: '1', + parentNotReal: '0', + }, + { + id: '2', + parentNotReal: '0', + }, + { + idNotReal: '3', + parent: '0', + }, + ]; + const leaves = getLeafNodes(results, ['0'], { id: 'id', parent: 'parent' }); + expect(leaves).toStrictEqual([]); + }); + + describe('with the ancestry field defined', () => { + it('correctly identifies the leaf nodes in a response with the ancestry field', () => { + /** + . + ├── 1 + │ └── 2 + └── 3 + */ + const results = [ + { + id: '1', + parent: '0', + ancestry: ['0', 'a'], + }, + { + id: '2', + parent: '1', + ancestry: ['1', '0'], + }, + { + id: '3', + parent: '0', + ancestry: ['0', 'a'], + }, + ]; + const leaves = getLeafNodes(results, ['0'], { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }); + expect(leaves).toStrictEqual(['2']); + }); + + it('falls back to using parent field if it cannot find the ancestry field', () => { + /** + . + ├── 1 + │ └── 2 + └── 3 + */ + const results = [ + { + id: '1', + parent: '0', + ancestryNotValid: ['0', 'a'], + }, + { + id: '2', + parent: '1', + }, + { + id: '3', + parent: '0', + }, + ]; + const leaves = getLeafNodes(results, ['0'], { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }); + expect(leaves).toStrictEqual(['1', '3']); + }); + + it('correctly identifies the leaf nodes with a tree with multiple leaves', () => { + /** + . + └── 0 + ├── 1 + │ └── 2 + └── 3 + ├── 4 + └── 5 + */ + const results = [ + { + id: '1', + parent: '0', + ancestry: ['0', 'a'], + }, + { + id: '2', + parent: '1', + ancestry: ['1', '0'], + }, + { + id: '3', + parent: '0', + ancestry: ['0', 'a'], + }, + { + id: '4', + parent: '3', + ancestry: ['3', '0'], + }, + { + id: '5', + parent: '3', + ancestry: ['3', '0'], + }, + ]; + const leaves = getLeafNodes(results, ['0'], { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }); + expect(leaves).toStrictEqual(['2', '4', '5']); + }); + + it('correctly identifies the leaf nodes with multiple queried nodes', () => { + /** + . + ├── 0 + │ ├── 1 + │ │ └── 2 + │ └── 3 + │ ├── 4 + │ └── 5 + └── a + └── b + ├── c + └── d + */ + const results = [ + { + id: '1', + parent: '0', + ancestry: ['0'], + }, + { + id: '2', + parent: '1', + ancestry: ['1', '0'], + }, + { + id: '3', + parent: '0', + ancestry: ['0'], + }, + { + id: '4', + parent: '3', + ancestry: ['3', '0'], + }, + { + id: '5', + parent: '3', + ancestry: ['3', '0'], + }, + { + id: 'b', + parent: 'a', + ancestry: ['a'], + }, + { + id: 'c', + parent: 'b', + ancestry: ['b', 'a'], + }, + { + id: 'd', + parent: 'b', + ancestry: ['b', 'a'], + }, + ]; + const leaves = getLeafNodes(results, ['0', 'a'], { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }); + expect(leaves).toStrictEqual(['2', '4', '5', 'c', 'd']); + }); + + it('correctly identifies the leaf nodes with an unbalanced tree', () => { + /** + . + ├── 0 + │ ├── 1 + │ │ └── 2 + │ └── 3 + │ ├── 4 + │ └── 5 + └── a + └── b + */ + const results = [ + { + id: '1', + parent: '0', + ancestry: ['0'], + }, + { + id: '2', + parent: '1', + ancestry: ['1', '0'], + }, + { + id: '3', + parent: '0', + ancestry: ['0'], + }, + { + id: '4', + parent: '3', + ancestry: ['3', '0'], + }, + { + id: '5', + parent: '3', + ancestry: ['3', '0'], + }, + { + id: 'b', + parent: 'a', + ancestry: ['a'], + }, + ]; + const leaves = getLeafNodes(results, ['0', 'a'], { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }); + // the reason b is not identified here is because the ancestry array + // size is 2, which means that if b had a descendant, then it would have been found + // using our query which found 2, 4, 5. So either we hit the size limit or there are no + // children of b + expect(leaves).toStrictEqual(['2', '4', '5']); + }); + }); + }); + + describe('getIDField', () => { + it('returns undefined if the field does not exist', () => { + expect(getIDField({}, { id: 'a', parent: 'b' })).toBeUndefined(); + }); + + it('returns the first value if the field is an array', () => { + expect(getIDField({ 'a.b': ['1', '2'] }, { id: 'a.b', parent: 'b' })).toStrictEqual('1'); + }); + }); + + describe('getParentField', () => { + it('returns undefined if the field does not exist', () => { + expect(getParentField({}, { id: 'a', parent: 'b' })).toBeUndefined(); + }); + + it('returns the first value if the field is an array', () => { + expect(getParentField({ 'a.b': ['1', '2'] }, { id: 'z', parent: 'a.b' })).toStrictEqual('1'); + }); + }); + + describe('getAncestryAsArray', () => { + it('returns an empty array if the field does not exist', () => { + expect(getAncestryAsArray({}, { id: 'a', parent: 'b', ancestry: 'z' })).toStrictEqual([]); + }); + + it('returns the full array if the field exists', () => { + expect( + getAncestryAsArray({ 'a.b': ['1', '2'] }, { id: 'z', parent: 'f', ancestry: 'a.b' }) + ).toStrictEqual(['1', '2']); + }); + + it('returns a built array using the parent field if ancestry field is empty', () => { + expect( + getAncestryAsArray( + { 'aParent.bParent': ['1', '2'], ancestry: [] }, + { id: 'z', parent: 'aParent.bParent', ancestry: 'ancestry' } + ) + ).toStrictEqual(['1']); + }); + + it('returns a built array using the parent field if ancestry field does not exist', () => { + expect( + getAncestryAsArray( + { 'aParent.bParent': '1' }, + { id: 'z', parent: 'aParent.bParent', ancestry: 'ancestry' } + ) + ).toStrictEqual(['1']); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts new file mode 100644 index 00000000000000..eaecad6c479704 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts @@ -0,0 +1,334 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IScopedClusterClient } from 'kibana/server'; +import { + firstNonNullValue, + values, +} from '../../../../../../common/endpoint/models/ecs_safety_helpers'; +import { ECSField, ResolverNode, FieldsObject } from '../../../../../../common/endpoint/types'; +import { DescendantsQuery } from '../queries/descendants'; +import { Schema, NodeID } from './index'; +import { LifecycleQuery } from '../queries/lifecycle'; +import { StatsQuery } from '../queries/stats'; + +/** + * The query parameters passed in from the request. These define the limits for the ES requests for retrieving the + * resolver tree. + */ +export interface TreeOptions { + descendantLevels: number; + descendants: number; + ancestors: number; + timerange: { + from: string; + to: string; + }; + schema: Schema; + nodes: NodeID[]; + indexPatterns: string[]; +} + +/** + * Handles retrieving nodes of a resolver tree. + */ +export class Fetcher { + constructor(private readonly client: IScopedClusterClient) {} + + /** + * This method retrieves the ancestors and descendants of a resolver tree. + * + * @param options the options for retrieving the structure of the tree. + */ + public async tree(options: TreeOptions): Promise { + const treeParts = await Promise.all([ + this.retrieveAncestors(options), + this.retrieveDescendants(options), + ]); + + const tree = treeParts.reduce((results, partArray) => { + results.push(...partArray); + return results; + }, []); + + return this.formatResponse(tree, options); + } + + private async formatResponse( + treeNodes: FieldsObject[], + options: TreeOptions + ): Promise { + const statsIDs: NodeID[] = []; + for (const node of treeNodes) { + const id = getIDField(node, options.schema); + if (id) { + statsIDs.push(id); + } + } + + const query = new StatsQuery({ + indexPatterns: options.indexPatterns, + schema: options.schema, + timerange: options.timerange, + }); + + const eventStats = await query.search(this.client, statsIDs); + const statsNodes: ResolverNode[] = []; + for (const node of treeNodes) { + const id = getIDField(node, options.schema); + const parent = getParentField(node, options.schema); + const name = getNameField(node, options.schema); + + // at this point id should never be undefined, it should be enforced by the Elasticsearch query + // but let's check anyway + if (id !== undefined) { + statsNodes.push({ + id, + parent, + name, + data: node, + stats: eventStats[id] ?? { total: 0, byCategory: {} }, + }); + } + } + return statsNodes; + } + + private static getNextAncestorsToFind( + results: FieldsObject[], + schema: Schema, + levelsLeft: number + ): NodeID[] { + const nodesByID = results.reduce((accMap: Map, result: FieldsObject) => { + const id = getIDField(result, schema); + if (id) { + accMap.set(id, result); + } + return accMap; + }, new Map()); + + const nodes: NodeID[] = []; + // Find all the nodes that don't have their parent in the result set, we will use these + // nodes to find the additional ancestry + for (const result of results) { + const parent = getParentField(result, schema); + if (parent) { + const parentNode = nodesByID.get(parent); + if (!parentNode) { + // it's ok if the nodes array is larger than the levelsLeft because the query + // will have the size set to the levelsLeft which will restrict the number of results + nodes.push(...getAncestryAsArray(result, schema).slice(0, levelsLeft)); + } + } + } + return nodes; + } + + private async retrieveAncestors(options: TreeOptions): Promise { + const ancestors: FieldsObject[] = []; + const query = new LifecycleQuery({ + schema: options.schema, + indexPatterns: options.indexPatterns, + timerange: options.timerange, + }); + + let nodes = options.nodes; + let numLevelsLeft = options.ancestors; + + while (numLevelsLeft > 0) { + const results: FieldsObject[] = await query.search(this.client, nodes); + if (results.length <= 0) { + return ancestors; + } + + /** + * This array (this.ancestry.ancestors) is the accumulated ancestors of the node of interest. This array is different + * from the ancestry array of a specific document. The order of this array is going to be weird, it will look like this + * [most distant ancestor...closer ancestor, next recursive call most distant ancestor...closer ancestor] + * + * Here is an example of why this happens + * Consider the following tree: + * A -> B -> C -> D -> E -> Origin + * Where A was spawn before B, which was before C, etc + * + * Let's assume the ancestry array limit is 2 so Origin's array would be: [E, D] + * E's ancestry array would be: [D, C] etc + * + * If a request comes in to retrieve all the ancestors in this tree, the accumulate results will be: + * [D, E, B, C, A] + * + * The first iteration would retrieve D and E in that order because they are sorted in ascending order by timestamp. + * The next iteration would get the ancestors of D (since that's the most distant ancestor from Origin) which are + * [B, C] + * The next iteration would get the ancestors of B which is A + * Hence: [D, E, B, C, A] + */ + ancestors.push(...results); + numLevelsLeft -= results.length; + nodes = Fetcher.getNextAncestorsToFind(results, options.schema, numLevelsLeft); + } + return ancestors; + } + + private async retrieveDescendants(options: TreeOptions): Promise { + const descendants: FieldsObject[] = []; + const query = new DescendantsQuery({ + schema: options.schema, + indexPatterns: options.indexPatterns, + timerange: options.timerange, + }); + + let nodes: NodeID[] = options.nodes; + let numNodesLeftToRequest: number = options.descendants; + let levelsLeftToRequest: number = options.descendantLevels; + // if the ancestry was specified then ignore the levels + while ( + numNodesLeftToRequest > 0 && + (options.schema.ancestry !== undefined || levelsLeftToRequest > 0) + ) { + const results: FieldsObject[] = await query.search(this.client, nodes, numNodesLeftToRequest); + if (results.length <= 0) { + return descendants; + } + + nodes = getLeafNodes(results, nodes, options.schema); + + numNodesLeftToRequest -= results.length; + levelsLeftToRequest -= 1; + descendants.push(...results); + } + + return descendants; + } +} + +/** + * This functions finds the leaf nodes for a given response from an Elasticsearch query. + * + * Exporting so it can be tested. + * + * @param results the doc values portion of the documents returned from an Elasticsearch query + * @param nodes an array of unique IDs that were used to find the returned documents + * @param schema the field definitions for how nodes are represented in the resolver graph + */ +export function getLeafNodes( + results: FieldsObject[], + nodes: Array, + schema: Schema +): NodeID[] { + let largestAncestryArray = 0; + const nodesToQueryNext: Map> = new Map(); + const queriedNodes = new Set(nodes); + const isDistantGrandchild = (event: FieldsObject) => { + const ancestry = getAncestryAsArray(event, schema); + return ancestry.length > 0 && queriedNodes.has(ancestry[ancestry.length - 1]); + }; + + for (const result of results) { + const ancestry = getAncestryAsArray(result, schema); + // This is to handle the following unlikely but possible scenario: + // if an alert was generated by the kernel process (parent process of all other processes) then + // the direct children of that process would only have an ancestry array of [parent_kernel], a single value in the array. + // The children of those children would have two values in their array [direct parent, parent_kernel] + // we need to determine which nodes are the most distant grandchildren of the queriedNodes because those should + // be used for the next query if more nodes should be retrieved. To generally determine the most distant grandchildren + // we can use the last entry in the ancestry array because of its ordering. The problem with that is in the scenario above + // the direct children of parent_kernel will also meet that criteria even though they are not actually the most + // distant grandchildren. To get around that issue we'll bucket all the nodes by the size of their ancestry array + // and then only return the nodes in the largest bucket because those should be the most distant grandchildren + // from the queried nodes that were passed in. + if (ancestry.length > largestAncestryArray) { + largestAncestryArray = ancestry.length; + } + + // a grandchild must have an array of > 0 and have it's last parent be in the set of previously queried nodes + // this is one of the furthest descendants from the queried nodes + if (isDistantGrandchild(result)) { + let levelOfNodes = nodesToQueryNext.get(ancestry.length); + if (!levelOfNodes) { + levelOfNodes = new Set(); + nodesToQueryNext.set(ancestry.length, levelOfNodes); + } + const nodeID = getIDField(result, schema); + if (nodeID) { + levelOfNodes.add(nodeID); + } + } + } + const nextNodes = nodesToQueryNext.get(largestAncestryArray); + + return nextNodes !== undefined ? Array.from(nextNodes) : []; +} + +/** + * Retrieves the unique ID field from a document. + * + * Exposed for testing. + * @param obj the doc value fields retrieved from a document returned by Elasticsearch + * @param schema the schema used for identifying connections between documents + */ +export function getIDField(obj: FieldsObject, schema: Schema): NodeID | undefined { + const id: ECSField = obj[schema.id]; + return firstNonNullValue(id); +} + +/** + * Retrieves the name field from a document. + * + * Exposed for testing. + * @param obj the doc value fields retrieved from a document returned by Elasticsearch + * @param schema the schema used for identifying connections between documents + */ +export function getNameField(obj: FieldsObject, schema: Schema): string | undefined { + if (!schema.name) { + return undefined; + } + + const name: ECSField = obj[schema.name]; + return String(firstNonNullValue(name)); +} + +/** + * Retrieves the unique parent ID field from a document. + * + * Exposed for testing. + * @param obj the doc value fields retrieved from a document returned by Elasticsearch + * @param schema the schema used for identifying connections between documents + */ +export function getParentField(obj: FieldsObject, schema: Schema): NodeID | undefined { + const parent: ECSField = obj[schema.parent]; + return firstNonNullValue(parent); +} + +function getAncestryField(obj: FieldsObject, schema: Schema): NodeID[] | undefined { + if (!schema.ancestry) { + return undefined; + } + + const ancestry: ECSField = obj[schema.ancestry]; + if (!ancestry) { + return undefined; + } + + return values(ancestry); +} + +/** + * Retrieves the ancestry array field if it exists. If it doesn't exist or if it is empty it reverts to + * creating an array using the parent field. If the parent field doesn't exist, it returns + * an empty array. + * + * Exposed for testing. + * @param obj the doc value fields retrieved from a document returned by Elasticsearch + * @param schema the schema used for identifying connections between documents + */ +export function getAncestryAsArray(obj: FieldsObject, schema: Schema): NodeID[] { + const ancestry = getAncestryField(obj, schema); + if (!ancestry || ancestry.length <= 0) { + const parentField = getParentField(obj, schema); + return parentField !== undefined ? [parentField] : []; + } + return ancestry; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts new file mode 100644 index 00000000000000..21a49e268310b2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Represents a time range filter + */ +export interface Timerange { + from: string; + to: string; +} + +/** + * An array of unique IDs to identify nodes within the resolver tree. + */ +export type NodeID = string | number; + +/** + * The fields to use to identify nodes within a resolver tree. + */ +export interface Schema { + /** + * the ancestry field should be set to a field that contains an order array representing + * the ancestors of a node. + */ + ancestry?: string; + /** + * id represents the field to use as the unique ID for a node. + */ + id: string; + /** + * field to use for the name of the node + */ + name?: string; + /** + * parent represents the field that is the edge between two nodes. + */ + parent: string; +} + +/** + * Returns the doc value fields filter to use in queries to limit the number of fields returned in the + * query response. + * + * See for more info: https://www.elastic.co/guide/en/elasticsearch/reference/current/search-fields.html#docvalue-fields + * + * @param schema is the node schema information describing how relationships are formed between nodes + * in the resolver graph. + */ +export function docValueFields(schema: Schema): Array<{ field: string }> { + const filter = [{ field: '@timestamp' }, { field: schema.id }, { field: schema.parent }]; + if (schema.ancestry) { + filter.push({ field: schema.ancestry }); + } + + if (schema.name) { + filter.push({ field: schema.name }); + } + return filter; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index 688036c59c8ffe..adf027a430f8ac 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -7,6 +7,7 @@ import { chunk } from 'lodash/fp'; import { extname } from 'path'; import { schema } from '@kbn/config-schema'; +import { createPromiseFromStreams } from '@kbn/utils'; import { validate } from '../../../../../common/validate'; import { @@ -20,7 +21,6 @@ import { } from '../../../../../common/detection_engine/schemas/response/import_rules_schema'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { IRouter } from '../../../../../../../../src/core/server'; -import { createPromiseFromStreams } from '../../../../../../../../src/core/server/utils/'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { ConfigType } from '../../../../config'; import { SetupPlugins } from '../../../../plugin'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts index 0bd6d43cab4649..b3b8296d90479a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { Readable } from 'stream'; +import { createPromiseFromStreams } from '@kbn/utils'; + import { transformAlertToRule, getIdError, @@ -22,7 +24,6 @@ import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; import { PartialFilter, RuleTypeParams } from '../../types'; import { BulkError, ImportSuccessError } from '../utils'; import { getOutputRuleAlertForRest } from '../__mocks__/utils'; -import { createPromiseFromStreams } from '../../../../../../../../src/core/server/utils'; import { PartialAlert } from '../../../../../../alerts/server'; import { SanitizedAlert } from '../../../../../../alerts/server/types'; import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts index 60071bc2cef41e..c6ec8f6df1892f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { Readable } from 'stream'; +import { createPromiseFromStreams } from '@kbn/utils'; import { createRulesStreamFromNdJson } from './create_rules_stream_from_ndjson'; -import { createPromiseFromStreams } from 'src/core/server/utils'; import { BadRequestError } from '../errors/bad_request_error'; import { ImportRulesSchemaDecoded } from '../../../../common/detection_engine/schemas/request/import_rules_schema'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts index cd574a8d95615e..b2c54038871285 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts @@ -7,6 +7,8 @@ import { Transform } from 'stream'; import * as t from 'io-ts'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; +import { createSplitStream, createMapStream, createConcatStream } from '@kbn/utils'; + import { formatErrors } from '../../../../common/format_errors'; import { importRuleValidateTypeDependents } from '../../../../common/detection_engine/schemas/request/import_rules_type_dependents'; import { exactCheck } from '../../../../common/exact_check'; @@ -15,11 +17,6 @@ import { ImportRulesSchema, ImportRulesSchemaDecoded, } from '../../../../common/detection_engine/schemas/request/import_rules_schema'; -import { - createSplitStream, - createMapStream, - createConcatStream, -} from '../../../../../../../src/core/server/utils'; import { BadRequestError } from '../errors/bad_request_error'; import { parseNdjsonStrings, diff --git a/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts b/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts index dfe45a00e05132..dd34003044884e 100644 --- a/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/hosts/query.hosts.dsl.ts @@ -44,7 +44,7 @@ export const buildHostsQuery = ({ index: defaultIndex, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { ...agg, host_data: { diff --git a/x-pack/plugins/security_solution/server/lib/hosts/query.last_first_seen_host.dsl.ts b/x-pack/plugins/security_solution/server/lib/hosts/query.last_first_seen_host.dsl.ts index 3bdaee58917ea0..320ac64a51c887 100644 --- a/x-pack/plugins/security_solution/server/lib/hosts/query.last_first_seen_host.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/hosts/query.last_first_seen_host.dsl.ts @@ -19,7 +19,7 @@ export const buildLastFirstSeenHostQuery = ({ index: defaultIndex, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { firstSeen: { min: { field: '@timestamp' } }, lastSeen: { max: { field: '@timestamp' } }, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/create_timelines_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/lib/timeline/create_timelines_stream_from_ndjson.ts index 2827cd373d5e75..8e795895764c74 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/create_timelines_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/create_timelines_stream_from_ndjson.ts @@ -9,11 +9,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { failure } from 'io-ts/lib/PathReporter'; import { identity } from 'fp-ts/lib/function'; -import { - createConcatStream, - createSplitStream, - createMapStream, -} from '../../../../../../src/core/server/utils'; +import { createConcatStream, createSplitStream, createMapStream } from '@kbn/utils'; import { parseNdjsonStrings, filterExportedCounts, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts index 13cc71840ec962..8c8cfad1100d65 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts @@ -79,7 +79,7 @@ describe('import timelines', () => { }; }); - jest.doMock('../../../../../../../src/core/server/utils', () => { + jest.doMock('@kbn/utils', () => { return { createPromiseFromStreams: jest.fn().mockReturnValue(mockParsedObjects), }; @@ -545,7 +545,7 @@ describe('import timeline templates', () => { }; }); - jest.doMock('../../../../../../../src/core/server/utils', () => { + jest.doMock('@kbn/utils', () => { return { createPromiseFromStreams: jest.fn().mockReturnValue(mockParsedTemplateTimelineObjects), }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/check_timelines_status.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/check_timelines_status.ts index b5aa24336b2d7c..3f7cf3f9760305 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/check_timelines_status.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/check_timelines_status.ts @@ -48,8 +48,8 @@ export const checkTimelinesStatus = async ( readStream, (timelinesFromFileSystem: T) => { if (Array.isArray(timelinesFromFileSystem)) { - const parsedTimelinesFromFileSystem = timelinesFromFileSystem.map((t: string) => - JSON.parse(t) + const parsedTimelinesFromFileSystem = (timelinesFromFileSystem as readonly string[]).map( + (t) => JSON.parse(t) ); const prepackagedTimelines = timeline.timeline ?? []; const timelinesToInstall = getTimelinesToInstall( diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts index 9a3dbf365e0266..488da5025531dc 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts @@ -7,11 +7,9 @@ import { set } from '@elastic/safer-lodash-set/fp'; import readline from 'readline'; import fs from 'fs'; import { Readable } from 'stream'; +import { createListStream } from '@kbn/utils'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; - -import { createListStream } from '../../../../../../../../src/core/server/utils'; - import { SetupPlugins } from '../../../../plugin'; import { FrameworkRequest } from '../../../framework'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts index a19b18e7d89b1a..f2b85b3ca0b477 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts @@ -7,6 +7,7 @@ import { has, chunk, omit } from 'lodash/fp'; import { Readable } from 'stream'; import uuid from 'uuid'; +import { createPromiseFromStreams } from '@kbn/utils'; import { TimelineStatus, @@ -21,7 +22,6 @@ import { createBulkErrorObject, BulkError } from '../../../detection_engine/rout import { createTimelines } from './create_timelines'; import { FrameworkRequest } from '../../../framework'; import { createTimelinesStreamFromNdJson } from '../../create_timelines_stream_from_ndjson'; -import { createPromiseFromStreams } from '../../../../../../../../src/core/server/utils'; import { getTupleDuplicateErrorsAndUniqueTimeline } from './get_timelines_from_stream'; import { CompareTimelinesStatus } from './compare_timelines_status'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.test.ts index c63978a1f046e1..c344e68faa7b99 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.test.ts @@ -5,7 +5,7 @@ */ import { join, resolve } from 'path'; -import { createPromiseFromStreams } from '../../../../../../../../src/core/server/utils'; +import { createPromiseFromStreams } from '@kbn/utils'; import { SecurityPluginSetup } from '../../../../../../security/server'; import { FrameworkRequest } from '../../../framework'; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts index b9f04502286e55..92fbe89c731d21 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts @@ -607,7 +607,60 @@ export const formattedSearchStrategyResponse = { loaded: 21, inspect: { dsl: [ - '{\n "allowNoIndices": true,\n "index": [\n "apm-*-transaction*",\n "auditbeat-*",\n "endgame-*",\n "filebeat-*",\n "logs-*",\n "packetbeat-*",\n "winlogbeat-*"\n ],\n "ignoreUnavailable": true,\n "body": {\n "aggregations": {\n "host_count": {\n "cardinality": {\n "field": "host.name"\n }\n },\n "host_data": {\n "terms": {\n "size": 10,\n "field": "host.name",\n "order": {\n "lastSeen": "desc"\n }\n },\n "aggs": {\n "lastSeen": {\n "max": {\n "field": "@timestamp"\n }\n },\n "os": {\n "top_hits": {\n "size": 1,\n "sort": [\n {\n "@timestamp": {\n "order": "desc"\n }\n }\n ],\n "_source": {\n "includes": [\n "host.os.*"\n ]\n }\n }\n }\n }\n }\n },\n "query": {\n "bool": {\n "filter": [\n {\n "bool": {\n "must": [],\n "filter": [\n {\n "match_all": {}\n }\n ],\n "should": [],\n "must_not": []\n }\n },\n {\n "range": {\n "@timestamp": {\n "gte": "2020-09-03T09:15:21.415Z",\n "lte": "2020-09-04T09:15:21.415Z",\n "format": "strict_date_optional_time"\n }\n }\n }\n ]\n }\n },\n "size": 0,\n "track_total_hits": false\n }\n}', + JSON.stringify( + { + allowNoIndices: true, + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + ignoreUnavailable: true, + body: { + docvalue_fields: mockOptions.docValueFields, + aggregations: { + host_count: { cardinality: { field: 'host.name' } }, + host_data: { + terms: { size: 10, field: 'host.name', order: { lastSeen: 'desc' } }, + aggs: { + lastSeen: { max: { field: '@timestamp' } }, + os: { + top_hits: { + size: 1, + sort: [{ '@timestamp': { order: 'desc' } }], + _source: { includes: ['host.os.*'] }, + }, + }, + }, + }, + }, + query: { + bool: { + filter: [ + { bool: { must: [], filter: [{ match_all: {} }], should: [], must_not: [] } }, + { + range: { + '@timestamp': { + gte: '2020-09-03T09:15:21.415Z', + lte: '2020-09-04T09:15:21.415Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + size: 0, + track_total_hits: false, + }, + }, + null, + 2 + ), ], }, edges: [ @@ -761,6 +814,7 @@ export const expectedDsl = { ], }, }, + docvalue_fields: mockOptions.docValueFields, size: 0, track_total_hits: false, }, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/query.all_hosts.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/query.all_hosts.dsl.ts index b257d9cfee4188..14c6288649da15 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/query.all_hosts.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/query.all_hosts.dsl.ts @@ -43,7 +43,7 @@ export const buildHostsQuery = ({ index: defaultIndex, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { ...agg, host_data: { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/__mocks__/index.ts index d1c63651997c7d..548c660f4dcd24 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/__mocks__/index.ts @@ -2159,6 +2159,7 @@ export const formattedSearchStrategyResponse = { ], ignoreUnavailable: true, body: { + docvalue_fields: mockOptions.docValueFields, aggregations: { user_count: { cardinality: { field: 'user.name' } }, group_by_users: { @@ -2379,6 +2380,7 @@ export const expectedDsl = { ], ignoreUnavailable: true, body: { + docvalue_fields: mockOptions.docValueFields, aggregations: { user_count: { cardinality: { field: 'user.name' } }, group_by_users: { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/dsl/query.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/dsl/query.dsl.ts index 08b83b489485a1..8ce17e1fec8b74 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/dsl/query.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/dsl/query.dsl.ts @@ -61,7 +61,7 @@ export const buildQuery = ({ index: defaultIndex, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { ...agg, group_by_users: { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/__mocks__/index.ts index 00427863c5f4b0..0418b6592af2a5 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/__mocks__/index.ts @@ -55,7 +55,32 @@ export const formattedSearchStrategyResponse = { loaded: 21, inspect: { dsl: [ - '{\n "allowNoIndices": true,\n "index": [\n "apm-*-transaction*",\n "auditbeat-*",\n "endgame-*",\n "filebeat-*",\n "logs-*",\n "packetbeat-*",\n "winlogbeat-*"\n ],\n "ignoreUnavailable": true,\n "body": {\n "docvalue_fields": [],\n "aggregations": {\n "firstSeen": {\n "min": {\n "field": "@timestamp"\n }\n },\n "lastSeen": {\n "max": {\n "field": "@timestamp"\n }\n }\n },\n "query": {\n "bool": {\n "filter": [\n {\n "term": {\n "host.name": "siem-kibana"\n }\n }\n ]\n }\n },\n "size": 0,\n "track_total_hits": false\n }\n}', + JSON.stringify( + { + allowNoIndices: true, + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + ignoreUnavailable: true, + body: { + aggregations: { + firstSeen: { min: { field: '@timestamp' } }, + lastSeen: { max: { field: '@timestamp' } }, + }, + query: { bool: { filter: [{ term: { 'host.name': 'siem-kibana' } }] } }, + size: 0, + track_total_hits: false, + }, + }, + null, + 2 + ), ], }, firstSeen: '2020-06-08T10:22:02.000Z', @@ -75,7 +100,6 @@ export const expectedDsl = { ], ignoreUnavailable: true, body: { - docvalue_fields: [], aggregations: { firstSeen: { min: { field: '@timestamp' } }, lastSeen: { max: { field: '@timestamp' } }, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/query.last_first_seen_host.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/query.last_first_seen_host.dsl.ts index 2c65f62b258a9f..6f7cc198b2cc1b 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/query.last_first_seen_host.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/query.last_first_seen_host.dsl.ts @@ -20,7 +20,7 @@ export const buildFirstLastSeenHostQuery = ({ index: defaultIndex, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { firstSeen: { min: { field: '@timestamp' } }, lastSeen: { max: { field: '@timestamp' } }, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/__mocks__/index.ts index c1c4db53ba4e36..09149ad128f1a9 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/__mocks__/index.ts @@ -1340,217 +1340,572 @@ export const mockDnsSearchStrategyResponse: IEsSearchResponse = { isPartial: false, isRunning: false, rawResponse: { - took: 150, + took: 36, timed_out: false, - _shards: { total: 21, successful: 21, skipped: 0, failed: 0 }, - hits: { total: 0, max_score: 0, hits: [] }, + _shards: { + total: 55, + successful: 55, + skipped: 38, + failed: 0, + }, + hits: { + max_score: 0, + hits: [], + total: 0, + }, aggregations: { - NetworkDns: { + dns_count: { + value: 3, + }, + dns_name_query_count: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, buckets: [ { - key_as_string: '2020-09-08T15:00:00.000Z', - key: 1599577200000, - doc_count: 7083, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T15:45:00.000Z', - key: 1599579900000, - doc_count: 146148, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T16:30:00.000Z', - key: 1599582600000, - doc_count: 65025, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T17:15:00.000Z', - key: 1599585300000, - doc_count: 62317, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T18:00:00.000Z', - key: 1599588000000, - doc_count: 58223, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T18:45:00.000Z', - key: 1599590700000, - doc_count: 55712, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T19:30:00.000Z', - key: 1599593400000, - doc_count: 55328, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T20:15:00.000Z', - key: 1599596100000, - doc_count: 63878, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T21:00:00.000Z', - key: 1599598800000, - doc_count: 54151, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T21:45:00.000Z', - key: 1599601500000, - doc_count: 55170, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T22:30:00.000Z', - key: 1599604200000, - doc_count: 43115, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T23:15:00.000Z', - key: 1599606900000, - doc_count: 52204, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T00:00:00.000Z', - key: 1599609600000, - doc_count: 43609, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T00:45:00.000Z', - key: 1599612300000, - doc_count: 44825, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T01:30:00.000Z', - key: 1599615000000, - doc_count: 52374, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T02:15:00.000Z', - key: 1599617700000, - doc_count: 44667, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T03:00:00.000Z', - key: 1599620400000, - doc_count: 45231, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T03:45:00.000Z', - key: 1599623100000, - doc_count: 42871, - dns: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, + key: 'google.com', + doc_count: 1, + unique_domains: { + value: 1, + }, + dns_question_name: { buckets: [ - { key: 'google.com', doc_count: 1, orderAgg: { value: 1 } }, - { key: 'google.internal', doc_count: 1, orderAgg: { value: 1 } }, + { + key_as_string: '2020-11-12T01:13:31.395Z', + key: 1605143611395, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:21:48.492Z', + key: 1605144108492, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:30:05.589Z', + key: 1605144605589, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:38:22.686Z', + key: 1605145102686, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:46:39.783Z', + key: 1605145599783, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:54:56.880Z', + key: 1605146096880, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:03:13.977Z', + key: 1605146593977, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:11:31.074Z', + key: 1605147091074, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:19:48.171Z', + key: 1605147588171, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:28:05.268Z', + key: 1605148085268, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:36:22.365Z', + key: 1605148582365, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:44:39.462Z', + key: 1605149079462, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:52:56.559Z', + key: 1605149576559, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:01:13.656Z', + key: 1605150073656, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:09:30.753Z', + key: 1605150570753, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:17:47.850Z', + key: 1605151067850, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:26:04.947Z', + key: 1605151564947, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:34:22.044Z', + key: 1605152062044, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:42:39.141Z', + key: 1605152559141, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:50:56.238Z', + key: 1605153056238, + doc_count: 1, + }, + { + key_as_string: '2020-11-12T03:59:13.335Z', + key: 1605153553335, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:07:30.432Z', + key: 1605154050432, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:15:47.529Z', + key: 1605154547529, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:24:04.626Z', + key: 1605155044626, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:32:21.723Z', + key: 1605155541723, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:40:38.820Z', + key: 1605156038820, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:48:55.917Z', + key: 1605156535917, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:57:13.014Z', + key: 1605157033014, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:05:30.111Z', + key: 1605157530111, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:13:47.208Z', + key: 1605158027208, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:22:04.305Z', + key: 1605158524305, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:30:21.402Z', + key: 1605159021402, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:38:38.499Z', + key: 1605159518499, + doc_count: 0, + }, ], }, + dns_bytes_in: { + value: 0, + }, + dns_bytes_out: { + value: 0, + }, }, { - key_as_string: '2020-09-09T04:30:00.000Z', - key: 1599625800000, - doc_count: 41327, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T05:15:00.000Z', - key: 1599628500000, - doc_count: 39860, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T06:00:00.000Z', - key: 1599631200000, - doc_count: 44061, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T06:45:00.000Z', - key: 1599633900000, - doc_count: 39193, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T07:30:00.000Z', - key: 1599636600000, - doc_count: 40909, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T08:15:00.000Z', - key: 1599639300000, - doc_count: 43293, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T09:00:00.000Z', - key: 1599642000000, - doc_count: 47640, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T09:45:00.000Z', - key: 1599644700000, - doc_count: 48605, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T10:30:00.000Z', - key: 1599647400000, - doc_count: 42072, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T11:15:00.000Z', - key: 1599650100000, - doc_count: 46398, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T12:00:00.000Z', - key: 1599652800000, - doc_count: 49378, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T12:45:00.000Z', - key: 1599655500000, - doc_count: 51171, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T13:30:00.000Z', - key: 1599658200000, - doc_count: 57911, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T14:15:00.000Z', - key: 1599660900000, - doc_count: 58909, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + key: 'google.internal', + doc_count: 1, + unique_domains: { + value: 1, + }, + dns_question_name: { + buckets: [ + { + key_as_string: '2020-11-12T01:13:31.395Z', + key: 1605143611395, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:21:48.492Z', + key: 1605144108492, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:30:05.589Z', + key: 1605144605589, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:38:22.686Z', + key: 1605145102686, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:46:39.783Z', + key: 1605145599783, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:54:56.880Z', + key: 1605146096880, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:03:13.977Z', + key: 1605146593977, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:11:31.074Z', + key: 1605147091074, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:19:48.171Z', + key: 1605147588171, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:28:05.268Z', + key: 1605148085268, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:36:22.365Z', + key: 1605148582365, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:44:39.462Z', + key: 1605149079462, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:52:56.559Z', + key: 1605149576559, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:01:13.656Z', + key: 1605150073656, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:09:30.753Z', + key: 1605150570753, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:17:47.850Z', + key: 1605151067850, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:26:04.947Z', + key: 1605151564947, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:34:22.044Z', + key: 1605152062044, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:42:39.141Z', + key: 1605152559141, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:50:56.238Z', + key: 1605153056238, + doc_count: 1, + }, + { + key_as_string: '2020-11-12T03:59:13.335Z', + key: 1605153553335, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:07:30.432Z', + key: 1605154050432, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:15:47.529Z', + key: 1605154547529, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:24:04.626Z', + key: 1605155044626, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:32:21.723Z', + key: 1605155541723, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:40:38.820Z', + key: 1605156038820, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:48:55.917Z', + key: 1605156535917, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:57:13.014Z', + key: 1605157033014, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:05:30.111Z', + key: 1605157530111, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:13:47.208Z', + key: 1605158027208, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:22:04.305Z', + key: 1605158524305, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:30:21.402Z', + key: 1605159021402, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:38:38.499Z', + key: 1605159518499, + doc_count: 0, + }, + ], + }, + dns_bytes_in: { + value: 0, + }, + dns_bytes_out: { + value: 0, + }, }, { - key_as_string: '2020-09-09T15:00:00.000Z', - key: 1599663600000, - doc_count: 62358, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + key: 'windows.net', + doc_count: 1, + unique_domains: { + value: 1, + }, + dns_question_name: { + buckets: [ + { + key_as_string: '2020-11-12T01:13:31.395Z', + key: 1605143611395, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:21:48.492Z', + key: 1605144108492, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:30:05.589Z', + key: 1605144605589, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:38:22.686Z', + key: 1605145102686, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:46:39.783Z', + key: 1605145599783, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:54:56.880Z', + key: 1605146096880, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:03:13.977Z', + key: 1605146593977, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:11:31.074Z', + key: 1605147091074, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:19:48.171Z', + key: 1605147588171, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:28:05.268Z', + key: 1605148085268, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:36:22.365Z', + key: 1605148582365, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:44:39.462Z', + key: 1605149079462, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:52:56.559Z', + key: 1605149576559, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:01:13.656Z', + key: 1605150073656, + doc_count: 1, + }, + { + key_as_string: '2020-11-12T03:09:30.753Z', + key: 1605150570753, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:17:47.850Z', + key: 1605151067850, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:26:04.947Z', + key: 1605151564947, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:34:22.044Z', + key: 1605152062044, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:42:39.141Z', + key: 1605152559141, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:50:56.238Z', + key: 1605153056238, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:59:13.335Z', + key: 1605153553335, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:07:30.432Z', + key: 1605154050432, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:15:47.529Z', + key: 1605154547529, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:24:04.626Z', + key: 1605155044626, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:32:21.723Z', + key: 1605155541723, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:40:38.820Z', + key: 1605156038820, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:48:55.917Z', + key: 1605156535917, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:57:13.014Z', + key: 1605157033014, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:05:30.111Z', + key: 1605157530111, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:13:47.208Z', + key: 1605158027208, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:22:04.305Z', + key: 1605158524305, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:30:21.402Z', + key: 1605159021402, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:38:38.499Z', + key: 1605159518499, + doc_count: 0, + }, + ], + }, + dns_bytes_in: { + value: 0, + }, + dns_bytes_out: { + value: 0, + }, }, ], }, @@ -1566,6 +1921,7 @@ export const formattedDnsSearchStrategyResponse: MatrixHistogramStrategyResponse dsl: [ JSON.stringify( { + allowNoIndices: true, index: [ 'apm-*-transaction*', 'auditbeat-*', @@ -1575,20 +1931,50 @@ export const formattedDnsSearchStrategyResponse: MatrixHistogramStrategyResponse 'packetbeat-*', 'winlogbeat-*', ], - allowNoIndices: true, ignoreUnavailable: true, body: { aggregations: { - NetworkDns: { - date_histogram: { field: '@timestamp', fixed_interval: '2700000ms' }, + dns_count: { + cardinality: { + field: 'dns.question.registered_domain', + }, + }, + dns_name_query_count: { + terms: { + field: 'dns.question.registered_domain', + size: 1000000, + }, aggs: { - dns: { - terms: { - field: 'dns.question.registered_domain', - order: { orderAgg: 'desc' }, + dns_question_name: { + date_histogram: { + field: '@timestamp', + fixed_interval: '2700000ms', + min_doc_count: 0, + extended_bounds: { min: 1599579675528, max: 1599666075529 }, + }, + }, + bucket_sort: { + bucket_sort: { + sort: [ + { + unique_domains: { + order: 'desc', + }, + }, + { + _key: { + order: 'asc', + }, + }, + ], + from: 0, size: 10, }, - aggs: { orderAgg: { cardinality: { field: 'dns.question.name' } } }, + }, + unique_domains: { + cardinality: { + field: 'dns.question.name', + }, }, }, }, @@ -1596,7 +1982,18 @@ export const formattedDnsSearchStrategyResponse: MatrixHistogramStrategyResponse query: { bool: { filter: [ - { bool: { must: [], filter: [{ match_all: {} }], should: [], must_not: [] } }, + { + bool: { + must: [], + filter: [ + { + match_all: {}, + }, + ], + should: [], + must_not: [], + }, + }, { range: { '@timestamp': { @@ -1607,11 +2004,20 @@ export const formattedDnsSearchStrategyResponse: MatrixHistogramStrategyResponse }, }, ], + must_not: [ + { + term: { + 'dns.question.type': { + value: 'PTR', + }, + }, + }, + ], }, }, - size: 0, - track_total_hits: true, }, + size: 0, + track_total_hits: false, }, null, 2 @@ -1619,8 +2025,105 @@ export const formattedDnsSearchStrategyResponse: MatrixHistogramStrategyResponse ], }, matrixHistogramData: [ - { x: 1599623100000, y: 1, g: 'google.com' }, - { x: 1599623100000, y: 1, g: 'google.internal' }, + { x: 1605143611395, y: 0, g: 'google.com' }, + { x: 1605144108492, y: 0, g: 'google.com' }, + { x: 1605144605589, y: 0, g: 'google.com' }, + { x: 1605145102686, y: 0, g: 'google.com' }, + { x: 1605145599783, y: 0, g: 'google.com' }, + { x: 1605146096880, y: 0, g: 'google.com' }, + { x: 1605146593977, y: 0, g: 'google.com' }, + { x: 1605147091074, y: 0, g: 'google.com' }, + { x: 1605147588171, y: 0, g: 'google.com' }, + { x: 1605148085268, y: 0, g: 'google.com' }, + { x: 1605148582365, y: 0, g: 'google.com' }, + { x: 1605149079462, y: 0, g: 'google.com' }, + { x: 1605149576559, y: 0, g: 'google.com' }, + { x: 1605150073656, y: 0, g: 'google.com' }, + { x: 1605150570753, y: 0, g: 'google.com' }, + { x: 1605151067850, y: 0, g: 'google.com' }, + { x: 1605151564947, y: 0, g: 'google.com' }, + { x: 1605152062044, y: 0, g: 'google.com' }, + { x: 1605152559141, y: 0, g: 'google.com' }, + { x: 1605153056238, y: 1, g: 'google.com' }, + { x: 1605153553335, y: 0, g: 'google.com' }, + { x: 1605154050432, y: 0, g: 'google.com' }, + { x: 1605154547529, y: 0, g: 'google.com' }, + { x: 1605155044626, y: 0, g: 'google.com' }, + { x: 1605155541723, y: 0, g: 'google.com' }, + { x: 1605156038820, y: 0, g: 'google.com' }, + { x: 1605156535917, y: 0, g: 'google.com' }, + { x: 1605157033014, y: 0, g: 'google.com' }, + { x: 1605157530111, y: 0, g: 'google.com' }, + { x: 1605158027208, y: 0, g: 'google.com' }, + { x: 1605158524305, y: 0, g: 'google.com' }, + { x: 1605159021402, y: 0, g: 'google.com' }, + { x: 1605159518499, y: 0, g: 'google.com' }, + { x: 1605143611395, y: 0, g: 'google.internal' }, + { x: 1605144108492, y: 0, g: 'google.internal' }, + { x: 1605144605589, y: 0, g: 'google.internal' }, + { x: 1605145102686, y: 0, g: 'google.internal' }, + { x: 1605145599783, y: 0, g: 'google.internal' }, + { x: 1605146096880, y: 0, g: 'google.internal' }, + { x: 1605146593977, y: 0, g: 'google.internal' }, + { x: 1605147091074, y: 0, g: 'google.internal' }, + { x: 1605147588171, y: 0, g: 'google.internal' }, + { x: 1605148085268, y: 0, g: 'google.internal' }, + { x: 1605148582365, y: 0, g: 'google.internal' }, + { x: 1605149079462, y: 0, g: 'google.internal' }, + { x: 1605149576559, y: 0, g: 'google.internal' }, + { x: 1605150073656, y: 0, g: 'google.internal' }, + { x: 1605150570753, y: 0, g: 'google.internal' }, + { x: 1605151067850, y: 0, g: 'google.internal' }, + { x: 1605151564947, y: 0, g: 'google.internal' }, + { x: 1605152062044, y: 0, g: 'google.internal' }, + { x: 1605152559141, y: 0, g: 'google.internal' }, + { x: 1605153056238, y: 1, g: 'google.internal' }, + { x: 1605153553335, y: 0, g: 'google.internal' }, + { x: 1605154050432, y: 0, g: 'google.internal' }, + { x: 1605154547529, y: 0, g: 'google.internal' }, + { x: 1605155044626, y: 0, g: 'google.internal' }, + { x: 1605155541723, y: 0, g: 'google.internal' }, + { x: 1605156038820, y: 0, g: 'google.internal' }, + { x: 1605156535917, y: 0, g: 'google.internal' }, + { x: 1605157033014, y: 0, g: 'google.internal' }, + { x: 1605157530111, y: 0, g: 'google.internal' }, + { x: 1605158027208, y: 0, g: 'google.internal' }, + { x: 1605158524305, y: 0, g: 'google.internal' }, + { x: 1605159021402, y: 0, g: 'google.internal' }, + { x: 1605159518499, y: 0, g: 'google.internal' }, + { x: 1605143611395, y: 0, g: 'windows.net' }, + { x: 1605144108492, y: 0, g: 'windows.net' }, + { x: 1605144605589, y: 0, g: 'windows.net' }, + { x: 1605145102686, y: 0, g: 'windows.net' }, + { x: 1605145599783, y: 0, g: 'windows.net' }, + { x: 1605146096880, y: 0, g: 'windows.net' }, + { x: 1605146593977, y: 0, g: 'windows.net' }, + { x: 1605147091074, y: 0, g: 'windows.net' }, + { x: 1605147588171, y: 0, g: 'windows.net' }, + { x: 1605148085268, y: 0, g: 'windows.net' }, + { x: 1605148582365, y: 0, g: 'windows.net' }, + { x: 1605149079462, y: 0, g: 'windows.net' }, + { x: 1605149576559, y: 0, g: 'windows.net' }, + { x: 1605150073656, y: 1, g: 'windows.net' }, + { x: 1605150570753, y: 0, g: 'windows.net' }, + { x: 1605151067850, y: 0, g: 'windows.net' }, + { x: 1605151564947, y: 0, g: 'windows.net' }, + { x: 1605152062044, y: 0, g: 'windows.net' }, + { x: 1605152559141, y: 0, g: 'windows.net' }, + { x: 1605153056238, y: 0, g: 'windows.net' }, + { x: 1605153553335, y: 0, g: 'windows.net' }, + { x: 1605154050432, y: 0, g: 'windows.net' }, + { x: 1605154547529, y: 0, g: 'windows.net' }, + { x: 1605155044626, y: 0, g: 'windows.net' }, + { x: 1605155541723, y: 0, g: 'windows.net' }, + { x: 1605156038820, y: 0, g: 'windows.net' }, + { x: 1605156535917, y: 0, g: 'windows.net' }, + { x: 1605157033014, y: 0, g: 'windows.net' }, + { x: 1605157530111, y: 0, g: 'windows.net' }, + { x: 1605158027208, y: 0, g: 'windows.net' }, + { x: 1605158524305, y: 0, g: 'windows.net' }, + { x: 1605159021402, y: 0, g: 'windows.net' }, + { x: 1605159518499, y: 0, g: 'windows.net' }, ], totalCount: 0, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/__mocks__/index.ts index 3a769127bbe853..6f1e593ca2edc5 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/__mocks__/index.ts @@ -18,55 +18,66 @@ export const mockOptions = { ], filterQuery: '{"bool":{"must":[],"filter":[{"match_all":{}}],"should":[],"must_not":[]}}', histogramType: MatrixHistogramType.dns, + isPtrIncluded: false, timerange: { interval: '12h', from: '2020-09-08T15:41:15.528Z', to: '2020-09-09T15:41:15.529Z' }, stackByField: 'dns.question.registered_domain', }; export const expectedDsl = { - index: [ - 'apm-*-transaction*', - 'auditbeat-*', - 'endgame-*', - 'filebeat-*', - 'logs-*', - 'packetbeat-*', - 'winlogbeat-*', - ], allowNoIndices: true, - ignoreUnavailable: true, body: { aggregations: { - NetworkDns: { - date_histogram: { field: '@timestamp', fixed_interval: '2700000ms' }, + dns_count: { cardinality: { field: 'dns.question.registered_domain' } }, + dns_name_query_count: { aggs: { - dns: { - terms: { - field: 'dns.question.registered_domain', - order: { orderAgg: 'desc' }, + bucket_sort: { + bucket_sort: { + from: 0, size: 10, + sort: [{ unique_domains: { order: 'desc' } }, { _key: { order: 'asc' } }], + }, + }, + dns_question_name: { + date_histogram: { + extended_bounds: { max: 1599666075529, min: 1599579675528 }, + field: '@timestamp', + fixed_interval: '2700000ms', + min_doc_count: 0, }, - aggs: { orderAgg: { cardinality: { field: 'dns.question.name' } } }, }, + unique_domains: { cardinality: { field: 'dns.question.name' } }, }, + terms: { field: 'dns.question.registered_domain', size: 1000000 }, }, }, query: { bool: { filter: [ - { bool: { must: [], filter: [{ match_all: {} }], should: [], must_not: [] } }, + { bool: { filter: [{ match_all: {} }], must: [], must_not: [], should: [] } }, { range: { '@timestamp': { + format: 'strict_date_optional_time', gte: '2020-09-08T15:41:15.528Z', lte: '2020-09-09T15:41:15.529Z', - format: 'strict_date_optional_time', }, }, }, ], + must_not: [{ term: { 'dns.question.type': { value: 'PTR' } } }], }, }, - size: 0, - track_total_hits: true, }, + ignoreUnavailable: true, + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + size: 0, + track_total_hits: false, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/helpers.ts index d0fff848b426a9..9131a9c4be87c9 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/helpers.ts @@ -17,15 +17,16 @@ export const getDnsParsedData = ( ): MatrixHistogramData[] => { let result: MatrixHistogramData[] = []; data.forEach((bucketData: unknown) => { - const time = get('key', bucketData); + const questionName = get('key', bucketData); const histData = getOr([], keyBucket, bucketData).map( // eslint-disable-next-line @typescript-eslint/naming-convention ({ key, doc_count }: DnsHistogramSubBucket) => ({ - x: time, + x: key, y: doc_count, - g: key, + g: questionName, }) ); + result = [...result, ...histData]; }); return result; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.test.ts index 8afc764d97f87b..fcdd13d42c91f8 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.test.ts @@ -19,8 +19,8 @@ jest.mock('./helpers', () => ({ describe('dnsMatrixHistogramConfig', () => { test('should export dnsMatrixHistogramConfig corrrectly', () => { expect(dnsMatrixHistogramConfig).toEqual({ - aggName: 'aggregations.NetworkDns.buckets', - parseKey: 'dns.buckets', + aggName: 'aggregations.dns_name_query_count.buckets', + parseKey: 'dns_question_name.buckets', buildDsl: buildDnsHistogramQuery, parser: getDnsParsedData, }); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.ts index 557e2ebf759e68..d592348de7afd5 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.ts @@ -9,7 +9,7 @@ import { getDnsParsedData } from './helpers'; export const dnsMatrixHistogramConfig = { buildDsl: buildDnsHistogramQuery, - aggName: 'aggregations.NetworkDns.buckets', - parseKey: 'dns.buckets', + aggName: 'aggregations.dns_name_query_count.buckets', + parseKey: 'dns_question_name.buckets', parser: getDnsParsedData, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/query.dns_histogram.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/query.dns_histogram.dsl.ts index 08a080865dfc06..4374d6b0da896f 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/query.dns_histogram.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/query.dns_histogram.dsl.ts @@ -4,17 +4,65 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; + +import moment from 'moment'; + +import { Direction, MatrixHistogramRequestOptions } from '../../../../../../common/search_strategy'; import { - createQueryFilterClauses, calculateTimeSeriesInterval, + createQueryFilterClauses, } from '../../../../../utils/build_query'; -import { MatrixHistogramRequestOptions } from '../../../../../../common/search_strategy/security_solution/matrix_histogram'; + +const HUGE_QUERY_SIZE = 1000000; + +const getCountAgg = () => ({ + dns_count: { + cardinality: { + field: 'dns.question.registered_domain', + }, + }, +}); + +const createIncludePTRFilter = (isPtrIncluded: boolean) => + isPtrIncluded + ? {} + : { + must_not: [ + { + term: { + 'dns.question.type': { + value: 'PTR', + }, + }, + }, + ], + }; + +const getHistogramAggregation = ({ from, to }: { from: string; to: string }) => { + const interval = calculateTimeSeriesInterval(from, to); + const histogramTimestampField = '@timestamp'; + + return { + date_histogram: { + field: histogramTimestampField, + fixed_interval: interval, + min_doc_count: 0, + extended_bounds: { + min: moment(from).valueOf(), + max: moment(to).valueOf(), + }, + }, + }; +}; export const buildDnsHistogramQuery = ({ + defaultIndex, + docValueFields, filterQuery, + isPtrIncluded = false, + stackByField = 'dns.question.registered_domain', timerange: { from, to }, - defaultIndex, - stackByField, }: MatrixHistogramRequestOptions) => { const filter = [ ...createQueryFilterClauses(filterQuery), @@ -29,55 +77,48 @@ export const buildDnsHistogramQuery = ({ }, ]; - const getHistogramAggregation = () => { - const interval = calculateTimeSeriesInterval(from, to); - const histogramTimestampField = '@timestamp'; - const dateHistogram = { - date_histogram: { - field: histogramTimestampField, - fixed_interval: interval, - }, - }; - - return { - NetworkDns: { - ...dateHistogram, - aggs: { - dns: { - terms: { - field: stackByField, - order: { - orderAgg: 'desc', + const dslQuery = { + allowNoIndices: true, + index: defaultIndex, + ignoreUnavailable: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + aggregations: { + ...getCountAgg(), + dns_name_query_count: { + terms: { + field: stackByField, + size: HUGE_QUERY_SIZE, + }, + aggs: { + dns_question_name: getHistogramAggregation({ from, to }), + bucket_sort: { + bucket_sort: { + sort: [ + { unique_domains: { order: Direction.desc } }, + { _key: { order: Direction.asc } }, + ], + from: 0, + size: 10, }, - size: 10, }, - aggs: { - orderAgg: { - cardinality: { - field: 'dns.question.name', - }, + unique_domains: { + cardinality: { + field: 'dns.question.name', }, }, }, }, }, - }; - }; - - const dslQuery = { - index: defaultIndex, - allowNoIndices: true, - ignoreUnavailable: true, - body: { - aggregations: getHistogramAggregation(), query: { bool: { filter, + ...createIncludePTRFilter(isPtrIncluded), }, }, - size: 0, - track_total_hits: true, }, + size: 0, + track_total_hits: false, }; return dslQuery; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts index fbe007622005c1..0379fa3d32edba 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts @@ -263,7 +263,101 @@ export const formattedSearchStrategyResponse = { ...mockSearchStrategyResponse, inspect: { dsl: [ - '{\n "allowNoIndices": true,\n "index": [\n "apm-*-transaction*",\n "auditbeat-*",\n "endgame-*",\n "filebeat-*",\n "logs-*",\n "packetbeat-*",\n "winlogbeat-*"\n ],\n "ignoreUnavailable": true,\n "body": {\n "aggs": {\n "source": {\n "filter": {\n "term": {\n "source.ip": "35.196.65.164"\n }\n },\n "aggs": {\n "firstSeen": {\n "min": {\n "field": "@timestamp"\n }\n },\n "lastSeen": {\n "max": {\n "field": "@timestamp"\n }\n },\n "as": {\n "filter": {\n "exists": {\n "field": "source.as"\n }\n },\n "aggs": {\n "results": {\n "top_hits": {\n "size": 1,\n "_source": [\n "source.as"\n ],\n "sort": [\n {\n "@timestamp": "desc"\n }\n ]\n }\n }\n }\n },\n "geo": {\n "filter": {\n "exists": {\n "field": "source.geo"\n }\n },\n "aggs": {\n "results": {\n "top_hits": {\n "size": 1,\n "_source": [\n "source.geo"\n ],\n "sort": [\n {\n "@timestamp": "desc"\n }\n ]\n }\n }\n }\n }\n }\n },\n "destination": {\n "filter": {\n "term": {\n "destination.ip": "35.196.65.164"\n }\n },\n "aggs": {\n "firstSeen": {\n "min": {\n "field": "@timestamp"\n }\n },\n "lastSeen": {\n "max": {\n "field": "@timestamp"\n }\n },\n "as": {\n "filter": {\n "exists": {\n "field": "destination.as"\n }\n },\n "aggs": {\n "results": {\n "top_hits": {\n "size": 1,\n "_source": [\n "destination.as"\n ],\n "sort": [\n {\n "@timestamp": "desc"\n }\n ]\n }\n }\n }\n },\n "geo": {\n "filter": {\n "exists": {\n "field": "destination.geo"\n }\n },\n "aggs": {\n "results": {\n "top_hits": {\n "size": 1,\n "_source": [\n "destination.geo"\n ],\n "sort": [\n {\n "@timestamp": "desc"\n }\n ]\n }\n }\n }\n }\n }\n },\n "host": {\n "filter": {\n "term": {\n "host.ip": "35.196.65.164"\n }\n },\n "aggs": {\n "results": {\n "top_hits": {\n "size": 1,\n "_source": [\n "host"\n ],\n "sort": [\n {\n "@timestamp": "desc"\n }\n ]\n }\n }\n }\n }\n },\n "query": {\n "bool": {\n "should": []\n }\n },\n "size": 0,\n "track_total_hits": false\n }\n}', + JSON.stringify( + { + allowNoIndices: true, + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + ignoreUnavailable: true, + body: { + docvalue_fields: mockOptions.docValueFields, + aggs: { + source: { + filter: { term: { 'source.ip': '35.196.65.164' } }, + aggs: { + firstSeen: { min: { field: '@timestamp' } }, + lastSeen: { max: { field: '@timestamp' } }, + as: { + filter: { exists: { field: 'source.as' } }, + aggs: { + results: { + top_hits: { + size: 1, + _source: ['source.as'], + sort: [{ '@timestamp': 'desc' }], + }, + }, + }, + }, + geo: { + filter: { exists: { field: 'source.geo' } }, + aggs: { + results: { + top_hits: { + size: 1, + _source: ['source.geo'], + sort: [{ '@timestamp': 'desc' }], + }, + }, + }, + }, + }, + }, + destination: { + filter: { term: { 'destination.ip': '35.196.65.164' } }, + aggs: { + firstSeen: { min: { field: '@timestamp' } }, + lastSeen: { max: { field: '@timestamp' } }, + as: { + filter: { exists: { field: 'destination.as' } }, + aggs: { + results: { + top_hits: { + size: 1, + _source: ['destination.as'], + sort: [{ '@timestamp': 'desc' }], + }, + }, + }, + }, + geo: { + filter: { exists: { field: 'destination.geo' } }, + aggs: { + results: { + top_hits: { + size: 1, + _source: ['destination.geo'], + sort: [{ '@timestamp': 'desc' }], + }, + }, + }, + }, + }, + }, + host: { + filter: { term: { 'host.ip': '35.196.65.164' } }, + aggs: { + results: { + top_hits: { size: 1, _source: ['host'], sort: [{ '@timestamp': 'desc' }] }, + }, + }, + }, + }, + query: { bool: { should: [] } }, + size: 0, + track_total_hits: false, + }, + }, + null, + 2 + ), ], }, networkDetails: { @@ -370,6 +464,7 @@ export const expectedDsl = { }, }, }, + docvalue_fields: mockOptions.docValueFields, query: { bool: { should: [] } }, size: 0, track_total_hits: false, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts index 67aeba60c4d2f3..9661915be6f3bb 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts @@ -106,7 +106,7 @@ export const buildNetworkDetailsQuery = ({ index: defaultIndex, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggs: { ...getAggs('source', ip), ...getAggs('destination', ip), diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/dns/query.dns_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/dns/query.dns_network.dsl.ts index 7043b15ebb4dd5..1da2e7475453f8 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/dns/query.dns_network.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/dns/query.dns_network.dsl.ts @@ -91,7 +91,7 @@ export const buildDnsQuery = ({ index: defaultIndex, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { ...getCountAgg(), dns_name_query_count: { @@ -102,7 +102,7 @@ export const buildDnsQuery = ({ aggs: { bucket_sort: { bucket_sort: { - sort: [getQueryOrder(sort), { _key: { order: 'asc' } }], + sort: [getQueryOrder(sort), { _key: { order: Direction.asc } }], from: cursorStart, size: querySize, }, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/__mocks__/index.ts index d105cb621cf465..8a3947924797fa 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/__mocks__/index.ts @@ -9,12 +9,12 @@ import { IEsSearchResponse } from '../../../../../../../../../../src/plugins/dat import { Direction, NetworkDnsFields, - NetworkDnsRequestOptions, + NetworkHttpRequestOptions, NetworkQueries, SortField, } from '../../../../../../../common/search_strategy'; -export const mockOptions: NetworkDnsRequestOptions = { +export const mockOptions: NetworkHttpRequestOptions = { defaultIndex: [ 'apm-*-transaction*', 'auditbeat-*', @@ -29,7 +29,7 @@ export const mockOptions: NetworkDnsRequestOptions = { pagination: { activePage: 0, cursorStart: 0, fakePossibleCount: 50, querySize: 10 }, sort: { direction: Direction.desc } as SortField, timerange: { interval: '12h', from: '2020-09-13T09:00:43.249Z', to: '2020-09-14T09:00:43.249Z' }, -} as NetworkDnsRequestOptions; +} as NetworkHttpRequestOptions; export const mockSearchStrategyResponse: IEsSearchResponse = { isPartial: false, diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts index 09551dd0693062..a5a0c877ecdd38 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts @@ -58,7 +58,7 @@ export const buildTimelineEventsAllQuery = ({ index: defaultIndex, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), query: { bool: { filter, diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts index 0f4eabf692919b..17563bfdbe249e 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts @@ -40,7 +40,7 @@ export const buildLastEventTimeQuery = ({ index: indicesToQuery.network, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { last_seen_event: { max: { field: '@timestamp' } }, }, @@ -58,7 +58,7 @@ export const buildLastEventTimeQuery = ({ index: indicesToQuery.hosts, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { last_seen_event: { max: { field: '@timestamp' } }, }, @@ -76,7 +76,7 @@ export const buildLastEventTimeQuery = ({ index: indicesToQuery[indexKey], ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { last_seen_event: { max: { field: '@timestamp' } }, }, diff --git a/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts index 4446e82f99de46..59769bb43815d9 100644 --- a/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts @@ -8,6 +8,8 @@ import { has, isString } from 'lodash/fp'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; +import { createMapStream, createFilterStream } from '@kbn/utils'; + import { formatErrors } from '../../../common/format_errors'; import { importRuleValidateTypeDependents } from '../../../common/detection_engine/schemas/request/import_rules_type_dependents'; import { @@ -16,7 +18,6 @@ import { ImportRulesSchema, } from '../../../common/detection_engine/schemas/request/import_rules_schema'; import { exactCheck } from '../../../common/exact_check'; -import { createMapStream, createFilterStream } from '../../../../../../src/core/server/utils'; import { BadRequestError } from '../../lib/detection_engine/errors/bad_request_error'; export interface RulesObjectsExportResultDetails { diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx index 2d7a56f33be848..99a9713521c514 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx @@ -37,6 +37,7 @@ export const HDFSSettings: React.FunctionComponent = ({ settingErrors, }) => { const { + name, settings: { delegateType, uri, diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_table/repository_table.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_table/repository_table.tsx index 4ccec36fba040a..335828a9856d38 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_table/repository_table.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_table/repository_table.tsx @@ -249,7 +249,11 @@ export const RepositoryTable: React.FunctionComponent = ({ , { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 42, - function: 'count > 4', + conditions: 'count greater than 4', }; const context = addMessages({ name: '[alert-name]' }, base, params); - expect(context.title).toMatchInlineSnapshot( - `"alert [alert-name] group [group] exceeded threshold"` - ); - expect(context.message).toMatchInlineSnapshot( - `"alert [alert-name] group [group] value 42 exceeded threshold count > 4 over 5m on 2020-01-01T00:00:00.000Z"` + expect(context.title).toMatchInlineSnapshot(`"alert [alert-name] group [group] met threshold"`); + expect(context.message).toEqual( + `alert '[alert-name]' is active for group '[group]': + +- Value: 42 +- Conditions Met: count greater than 4 over 5m +- Timestamp: 2020-01-01T00:00:00.000Z` ); }); @@ -54,14 +56,16 @@ describe('ActionContext', () => { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 42, - function: 'avg([aggField]) > 4.2', + conditions: 'avg([aggField]) greater than 4.2', }; const context = addMessages({ name: '[alert-name]' }, base, params); - expect(context.title).toMatchInlineSnapshot( - `"alert [alert-name] group [group] exceeded threshold"` - ); - expect(context.message).toMatchInlineSnapshot( - `"alert [alert-name] group [group] value 42 exceeded threshold avg([aggField]) > 4.2 over 5m on 2020-01-01T00:00:00.000Z"` + expect(context.title).toMatchInlineSnapshot(`"alert [alert-name] group [group] met threshold"`); + expect(context.message).toEqual( + `alert '[alert-name]' is active for group '[group]': + +- Value: 42 +- Conditions Met: avg([aggField]) greater than 4.2 over 5m +- Timestamp: 2020-01-01T00:00:00.000Z` ); }); @@ -82,14 +86,16 @@ describe('ActionContext', () => { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 4, - function: 'count between 4,5', + conditions: 'count between 4 and 5', }; const context = addMessages({ name: '[alert-name]' }, base, params); - expect(context.title).toMatchInlineSnapshot( - `"alert [alert-name] group [group] exceeded threshold"` - ); - expect(context.message).toMatchInlineSnapshot( - `"alert [alert-name] group [group] value 4 exceeded threshold count between 4,5 over 5m on 2020-01-01T00:00:00.000Z"` + expect(context.title).toMatchInlineSnapshot(`"alert [alert-name] group [group] met threshold"`); + expect(context.message).toEqual( + `alert '[alert-name]' is active for group '[group]': + +- Value: 4 +- Conditions Met: count between 4 and 5 over 5m +- Timestamp: 2020-01-01T00:00:00.000Z` ); }); }); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts index 9bb0df9d07fd45..aabb020fcf0c7a 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts @@ -27,8 +27,8 @@ export interface BaseActionContext extends AlertInstanceContext { date: string; // the value that met the threshold value: number; - // the function that is used - function: string; + // threshold conditions + conditions: string; } export function addMessages( @@ -37,7 +37,7 @@ export function addMessages( params: Params ): ActionContext { const title = i18n.translate('xpack.stackAlerts.indexThreshold.alertTypeContextSubjectTitle', { - defaultMessage: 'alert {name} group {group} exceeded threshold', + defaultMessage: 'alert {name} group {group} met threshold', values: { name: alertInfo.name, group: baseContext.group, @@ -48,13 +48,16 @@ export function addMessages( const message = i18n.translate( 'xpack.stackAlerts.indexThreshold.alertTypeContextMessageDescription', { - defaultMessage: - 'alert {name} group {group} value {value} exceeded threshold {function} over {window} on {date}', + defaultMessage: `alert '{name}' is active for group '{group}': + +- Value: {value} +- Conditions Met: {conditions} over {window} +- Timestamp: {date}`, values: { name: alertInfo.name, group: baseContext.group, value: baseContext.value, - function: baseContext.function, + conditions: baseContext.conditions, window, date: baseContext.date, }, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts index 0febe805af4e09..ad6da351b1016d 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts @@ -46,7 +46,7 @@ describe('alertType', () => { }, Object { "description": "A string describing the threshold comparator and threshold", - "name": "function", + "name": "conditions", }, ], "params": Array [ diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 2d9e1b3adc1b89..9de09407715256 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -17,6 +17,24 @@ import { export const ID = '.index-threshold'; +enum Comparator { + GT = '>', + LT = '<', + GT_OR_EQ = '>=', + LT_OR_EQ = '<=', + BETWEEN = 'between', + NOT_BETWEEN = 'notBetween', +} + +const humanReadableComparators = new Map([ + [Comparator.LT, 'less than'], + [Comparator.LT_OR_EQ, 'less than or equal to'], + [Comparator.GT_OR_EQ, 'greater than or equal to'], + [Comparator.GT, 'greater than'], + [Comparator.BETWEEN, 'between'], + [Comparator.NOT_BETWEEN, 'not between'], +]); + const ActionGroupId = 'threshold met'; const ComparatorFns = getComparatorFns(); export const ComparatorFnNames = new Set(ComparatorFns.keys()); @@ -86,8 +104,8 @@ export function getAlertType( } ); - const actionVariableContextFunctionLabel = i18n.translate( - 'xpack.stackAlerts.indexThreshold.actionVariableContextFunctionLabel', + const actionVariableContextConditionsLabel = i18n.translate( + 'xpack.stackAlerts.indexThreshold.actionVariableContextConditionsLabel', { defaultMessage: 'A string describing the threshold comparator and threshold', } @@ -117,7 +135,7 @@ export function getAlertType( { name: 'group', description: actionVariableContextGroupLabel }, { name: 'date', description: actionVariableContextDateLabel }, { name: 'value', description: actionVariableContextValueLabel }, - { name: 'function', description: actionVariableContextFunctionLabel }, + { name: 'conditions', description: actionVariableContextConditionsLabel }, ], params: [ { name: 'threshold', description: actionVariableContextThresholdLabel }, @@ -172,13 +190,15 @@ export function getAlertType( if (!met) continue; const agg = params.aggField ? `${params.aggType}(${params.aggField})` : `${params.aggType}`; - const humanFn = `${agg} ${params.thresholdComparator} ${params.threshold.join(',')}`; + const humanFn = `${agg} is ${getHumanReadableComparator( + params.thresholdComparator + )} ${params.threshold.join(' and ')}`; const baseContext: BaseActionContext = { date, group: instanceId, value, - function: humanFn, + conditions: humanFn, }; const actionContext = addMessages(options, baseContext, params); const alertInstance = options.services.alertInstanceFactory(instanceId); @@ -201,12 +221,13 @@ type ComparatorFn = (value: number, threshold: number[]) => boolean; function getComparatorFns(): Map { const fns: Record = { - '<': (value: number, threshold: number[]) => value < threshold[0], - '<=': (value: number, threshold: number[]) => value <= threshold[0], - '>=': (value: number, threshold: number[]) => value >= threshold[0], - '>': (value: number, threshold: number[]) => value > threshold[0], - between: (value: number, threshold: number[]) => value >= threshold[0] && value <= threshold[1], - notBetween: (value: number, threshold: number[]) => + [Comparator.LT]: (value: number, threshold: number[]) => value < threshold[0], + [Comparator.LT_OR_EQ]: (value: number, threshold: number[]) => value <= threshold[0], + [Comparator.GT_OR_EQ]: (value: number, threshold: number[]) => value >= threshold[0], + [Comparator.GT]: (value: number, threshold: number[]) => value > threshold[0], + [Comparator.BETWEEN]: (value: number, threshold: number[]) => + value >= threshold[0] && value <= threshold[1], + [Comparator.NOT_BETWEEN]: (value: number, threshold: number[]) => value < threshold[0] || value > threshold[1], }; @@ -217,3 +238,9 @@ function getComparatorFns(): Map { return result; } + +function getHumanReadableComparator(comparator: string) { + return humanReadableComparators.has(comparator) + ? humanReadableComparators.get(comparator) + : comparator; +} diff --git a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts index d6d776f970a329..e4ee5b30ab084f 100644 --- a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts +++ b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts @@ -69,7 +69,7 @@ describe('Bulk Operation Buffer', () => { const task3 = createTask(); const task4 = createTask(); - return new Promise((resolve) => { + return new Promise((resolve) => { Promise.all([bufferedUpdate(task1), bufferedUpdate(task2)]).then((_) => { expect(bulkUpdate).toHaveBeenCalledTimes(1); expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); @@ -146,7 +146,7 @@ describe('Bulk Operation Buffer', () => { expect(bulkUpdate).toHaveBeenCalledTimes(1); expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); - return new Promise((resolve) => { + return new Promise((resolve) => { const futureUpdates = Promise.all([bufferedUpdate(task3), bufferedUpdate(task4)]); setTimeout(() => { diff --git a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts index f97861901b5b5b..6f3dcb33d5bf5b 100644 --- a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts @@ -36,7 +36,7 @@ describe('Configuration Statistics Aggregator', () => { pollIntervalConfiguration$: new Subject(), }; - return new Promise(async (resolve, reject) => { + return new Promise(async (resolve, reject) => { createConfigurationAggregator(configuration, managedConfig) .pipe(take(3), bufferCount(3)) .subscribe(([initial, updatedWorkers, updatedInterval]) => { diff --git a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts index 8479def5deeebb..b8502dee9a8efa 100644 --- a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts @@ -36,7 +36,7 @@ describe('createMonitoringStatsStream', () => { }; it('returns the initial config used to configure Task Manager', async () => { - return new Promise((resolve) => { + return new Promise((resolve) => { createMonitoringStatsStream(of(), configuration) .pipe(take(1)) .subscribe((firstValue) => { @@ -49,7 +49,7 @@ describe('createMonitoringStatsStream', () => { it('incrementally updates the stats returned by the endpoint', async () => { const aggregatedStats$ = new Subject(); - return new Promise((resolve) => { + return new Promise((resolve) => { createMonitoringStatsStream(aggregatedStats$, configuration) .pipe(take(3), bufferCount(3)) .subscribe(([initialValue, secondValue, thirdValue]) => { diff --git a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts index 6ab866b6167acc..538acd51bb792c 100644 --- a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts @@ -60,7 +60,7 @@ describe('Task Run Statistics', () => { }); } - return new Promise((resolve) => { + return new Promise((resolve) => { taskRunAggregator .pipe( // skip initial stat which is just initialized data which @@ -128,7 +128,7 @@ describe('Task Run Statistics', () => { } } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { taskRunAggregator .pipe( // skip initial stat which is just initialized data which @@ -224,7 +224,7 @@ describe('Task Run Statistics', () => { runningAverageWindowSize ); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { taskRunAggregator .pipe( // skip initial stat which is just initialized data which @@ -303,7 +303,7 @@ describe('Task Run Statistics', () => { runningAverageWindowSize ); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { taskRunAggregator .pipe( // skip initial stat which is just initialized data which @@ -394,7 +394,7 @@ describe('Task Run Statistics', () => { runningAverageWindowSize ); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { taskRunAggregator .pipe( // skip initial stat which is just initialized data which diff --git a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts index 3470ee4d764864..21c9f429814cac 100644 --- a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts @@ -86,7 +86,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe(() => { expect(taskStore.aggregate).toHaveBeenCalledWith({ aggs: { @@ -253,7 +253,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe((result) => { expect(result.key).toEqual('workload'); expect(result.value).toMatchObject({ @@ -283,7 +283,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise(async (resolve) => { + return new Promise(async (resolve) => { workloadAggregator.pipe(first()).subscribe((result) => { expect(result.key).toEqual('workload'); expect(result.value).toMatchObject({ @@ -319,7 +319,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe((result) => { expect(result.key).toEqual('workload'); expect(result.value).toMatchObject({ @@ -342,7 +342,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe((result) => { expect(result.key).toEqual('workload'); expect(result.value).toMatchObject({ @@ -370,7 +370,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe(() => { expect(taskStore.aggregate.mock.calls[0][0]).toMatchObject({ aggs: { @@ -408,7 +408,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe((result) => { expect(taskStore.aggregate.mock.calls[0][0]).toMatchObject({ aggs: { @@ -453,7 +453,7 @@ describe('Workload Statistics Aggregator', () => { const logger = loggingSystemMock.create().get(); const workloadAggregator = createWorkloadAggregator(taskStore, of(true), 10, 3000, logger); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { workloadAggregator.pipe(take(2), bufferCount(2)).subscribe((results) => { expect(results[0].key).toEqual('workload'); expect(results[0].value).toMatchObject({ @@ -491,7 +491,7 @@ describe('Workload Statistics Aggregator', () => { logger ); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let errorWasThrowAt = 0; taskStore.aggregate.mockImplementation(async () => { if (errorWasThrowAt === 0) { diff --git a/x-pack/plugins/task_manager/server/polling/observable_monitor.test.ts b/x-pack/plugins/task_manager/server/polling/observable_monitor.test.ts index 0b7bbdfb623e5f..8fe2d59eee1250 100644 --- a/x-pack/plugins/task_manager/server/polling/observable_monitor.test.ts +++ b/x-pack/plugins/task_manager/server/polling/observable_monitor.test.ts @@ -25,7 +25,7 @@ describe('Poll Monitor', () => { expect(instantiator).not.toHaveBeenCalled(); - return new Promise((resolve) => { + return new Promise((resolve) => { const next = jest.fn(); monitoredObservable.pipe(take(3)).subscribe({ next, @@ -45,7 +45,7 @@ describe('Poll Monitor', () => { const instantiator = jest.fn(() => interval(100)); const monitoredObservable = createObservableMonitor(instantiator, { heartbeatInterval }); - return new Promise((resolve) => { + return new Promise((resolve) => { const next = jest.fn(); monitoredObservable.pipe(take(3)).subscribe({ next, @@ -79,7 +79,7 @@ describe('Poll Monitor', () => { const onError = jest.fn(); const monitoredObservable = createObservableMonitor(instantiator, { onError }); - return new Promise((resolve) => { + return new Promise((resolve) => { const next = jest.fn(); const error = jest.fn(); monitoredObservable @@ -135,7 +135,7 @@ describe('Poll Monitor', () => { inactivityTimeout: 500, }); - return new Promise((resolve) => { + return new Promise((resolve) => { const next = jest.fn(); const error = jest.fn(); monitoredObservable diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 8936cdafa38279..e1b5f4cb9c3ae0 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -1778,9 +1778,9 @@ } } }, - "ingest_manager": { + "fleet": { "properties": { - "fleet_enabled": { + "agents_enabled": { "type": "boolean" }, "agents": { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 91acad0942739b..9a28e0e53bef50 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3453,7 +3453,6 @@ "timelion.serverSideErrors.wrongFunctionArgumentTypeErrorMessage": "{functionName} ({argumentName}) は {requiredTypes} の内の 1 つでなければなりません。{actualType} を入手", "timelion.serverSideErrors.yaxisFunction.notSupportedUnitTypeErrorMessage": "{units} はサポートされているユニットタイプではありません。.", "timelion.serverSideErrors.yaxisFunction.notValidCurrencyFormatErrorMessage": "通貨は 3 文字のコードでなければなりません", - "timelion.timelionDescription": "関数式で時系列チャートを作成します。", "timelion.topNavMenu.addChartButtonAriaLabel": "チャートを追加", "timelion.topNavMenu.addChartButtonLabel": "追加", "timelion.topNavMenu.delete.modal.confirmButtonLabel": "削除", @@ -3693,7 +3692,6 @@ "visTypeMetric.function.showLabels.help": "メトリック値の下にラベルを表示します。", "visTypeMetric.function.subText.help": "メトリックの下に表示するカスタムテキスト", "visTypeMetric.function.useRanges.help": "有効な色範囲です。", - "visTypeMetric.metricDescription": "計算結果を単独の数字として表示します。", "visTypeMetric.metricTitle": "メトリック", "visTypeMetric.params.color.useForLabel": "使用する色", "visTypeMetric.params.percentageModeLabel": "パーセンテージモード", @@ -3719,11 +3717,9 @@ "visTypeTable.params.showPartialRowsTip": "部分データのある行を表示。表示されていなくてもすべてのバケット/レベルのメトリックが計算されます。", "visTypeTable.params.showTotalLabel": "合計を表示", "visTypeTable.params.totalFunctionLabel": "合計機能", - "visTypeTable.tableVisDescription": "テーブルに値を表示します。", "visTypeTable.tableVisEditorConfig.schemas.bucketTitle": "行を分割", "visTypeTable.tableVisEditorConfig.schemas.metricTitle": "メトリック", "visTypeTable.tableVisEditorConfig.schemas.splitTitle": "テーブルを分割", - "visTypeTable.tableVisTitle": "データテーブル", "visTypeTable.totalAggregations.averageText": "平均", "visTypeTable.totalAggregations.countText": "カウント", "visTypeTable.totalAggregations.maxText": "最高", @@ -3745,8 +3741,6 @@ "visTypeTagCloud.vis.editorConfig.scales.squareRootText": "平方根", "visTypeTagCloud.vis.schemas.metricTitle": "タグサイズ", "visTypeTagCloud.vis.schemas.segmentTitle": "タグ", - "visTypeTagCloud.vis.tagCloudDescription": "重要度に基づき大きさを変えた単語のグループ表示です。", - "visTypeTagCloud.vis.tagCloudTitle": "タグクラウド", "visTypeTagCloud.visParams.fontSizeLabel": "フォントサイズ範囲 (ピクセル)", "visTypeTagCloud.visParams.orientationsLabel": "方向", "visTypeTagCloud.visParams.showLabelToggleLabel": "ラベルを表示", @@ -4344,7 +4338,6 @@ "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsText": "1つのデータソースが返せるバケットの最大数です。値が大きいとブラウザのレンダリング速度が下がる可能性があります。", "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsTitle": "ヒートマップの最大バケット数", "visTypeVislib.aggResponse.allDocsTitle": "すべてのドキュメント", - "visTypeVislib.area.areaDescription": "折れ線グラフの下の数量を強調します。", "visTypeVislib.area.areaTitle": "エリア", "visTypeVislib.area.countText": "カウント", "visTypeVislib.area.groupTitle": "系列を分割", @@ -4463,26 +4456,19 @@ "visTypeVislib.gauge.gaugeTypes.circleText": "円", "visTypeVislib.gauge.groupTitle": "グループを分割", "visTypeVislib.gauge.metricTitle": "メトリック", - "visTypeVislib.goal.goalDescription": "ゴールチャートは、最終目標にどれだけ近いかを示します。", "visTypeVislib.goal.goalTitle": "ゴール", "visTypeVislib.goal.groupTitle": "グループを分割", "visTypeVislib.goal.metricTitle": "メトリック", "visTypeVislib.heatmap.groupTitle": "Y 軸", - "visTypeVislib.heatmap.heatmapDescription": "マトリックス内のセルに影をつける。", - "visTypeVislib.heatmap.heatmapTitle": "ヒートマップ", "visTypeVislib.heatmap.metricTitle": "値", "visTypeVislib.heatmap.segmentTitle": "X 軸", "visTypeVislib.heatmap.splitTitle": "チャートを分割", "visTypeVislib.histogram.groupTitle": "系列を分割", - "visTypeVislib.histogram.histogramDescription": "連続変数を各軸に割り当てる。", - "visTypeVislib.histogram.histogramTitle": "縦棒", "visTypeVislib.histogram.metricTitle": "Y 軸", "visTypeVislib.histogram.radiusTitle": "点のサイズ", "visTypeVislib.histogram.segmentTitle": "X 軸", "visTypeVislib.histogram.splitTitle": "チャートを分割", "visTypeVislib.horizontalBar.groupTitle": "系列を分割", - "visTypeVislib.horizontalBar.horizontalBarDescription": "連続変数を各軸に割り当てる。", - "visTypeVislib.horizontalBar.horizontalBarTitle": "横棒", "visTypeVislib.horizontalBar.metricTitle": "Y 軸", "visTypeVislib.horizontalBar.radiusTitle": "点のサイズ", "visTypeVislib.horizontalBar.segmentTitle": "X 軸", @@ -4495,14 +4481,12 @@ "visTypeVislib.legendPositions.rightText": "右", "visTypeVislib.legendPositions.topText": "トップ", "visTypeVislib.line.groupTitle": "系列を分割", - "visTypeVislib.line.lineDescription": "トレンドを強調します。", "visTypeVislib.line.lineTitle": "折れ線", "visTypeVislib.line.metricTitle": "Y 軸", "visTypeVislib.line.radiusTitle": "点のサイズ", "visTypeVislib.line.segmentTitle": "X 軸", "visTypeVislib.line.splitTitle": "チャートを分割", "visTypeVislib.pie.metricTitle": "サイズのスライス", - "visTypeVislib.pie.pieDescription": "全体に対する内訳を表現する。", "visTypeVislib.pie.pieTitle": "パイ", "visTypeVislib.pie.segmentTitle": "スライスの分割", "visTypeVislib.pie.splitTitle": "チャートを分割", @@ -7280,7 +7264,6 @@ "xpack.fleet.agentPolicyList.pageSubtitle": "エージェントポリシーを使用すると、エージェントとエージェントが収集するデータを管理できます。", "xpack.fleet.agentPolicyList.pageTitle": "エージェントポリシー", "xpack.fleet.agentPolicyList.reloadAgentPoliciesButtonText": "再読み込み", - "xpack.fleet.agentPolicyList.revisionNumber": "rev. {revNumber}", "xpack.fleet.agentPolicyList.updatedOnColumnTitle": "最終更新日", "xpack.fleet.agentReassignPolicy.cancelButtonLabel": "キャンセル", "xpack.fleet.agentReassignPolicy.continueButtonLabel": "ポリシーの割り当て", @@ -19447,7 +19430,6 @@ "xpack.stackAlerts.indexThreshold.actionVariableContextThresholdLabel": "しきい値として使用する値の配列。「between」と「notBetween」には2つの値が必要です。その他は1つの値が必要です。", "xpack.stackAlerts.indexThreshold.actionVariableContextTitleLabel": "アラートの事前構成タイトル。", "xpack.stackAlerts.indexThreshold.actionVariableContextValueLabel": "しきい値を超えた値。", - "xpack.stackAlerts.indexThreshold.alertTypeContextMessageDescription": "アラート{name}グループ{group}値{value}が{date}に{window}にわたってしきい値{function}を超えました", "xpack.stackAlerts.indexThreshold.alertTypeContextSubjectTitle": "アラート{name}グループ{group}がしきい値を超えました", "xpack.stackAlerts.indexThreshold.alertTypeTitle": "インデックスしきい値", "xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "無効なthresholdComparatorが指定されました: {comparator}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index adc448ba703eec..66a00c30bd3b98 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3454,7 +3454,6 @@ "timelion.serverSideErrors.wrongFunctionArgumentTypeErrorMessage": "{functionName}({argumentName}) 必须是 {requiredTypes} 之一。得到:{actualType}", "timelion.serverSideErrors.yaxisFunction.notSupportedUnitTypeErrorMessage": "{units} 为不受支持的单元类型。", "timelion.serverSideErrors.yaxisFunction.notValidCurrencyFormatErrorMessage": "货币必须使用三个字母的代码", - "timelion.timelionDescription": "使用函数表达式构建时间序列", "timelion.topNavMenu.addChartButtonAriaLabel": "添加图表", "timelion.topNavMenu.addChartButtonLabel": "添加", "timelion.topNavMenu.delete.modal.confirmButtonLabel": "删除", @@ -3694,7 +3693,6 @@ "visTypeMetric.function.showLabels.help": "在指标值下显示标签。", "visTypeMetric.function.subText.help": "要在指标下显示的定制文本", "visTypeMetric.function.useRanges.help": "已启用颜色范围。", - "visTypeMetric.metricDescription": "将计算结果显示为单个数字", "visTypeMetric.metricTitle": "指标", "visTypeMetric.params.color.useForLabel": "将颜色用于", "visTypeMetric.params.percentageModeLabel": "百分比模式", @@ -3720,11 +3718,9 @@ "visTypeTable.params.showPartialRowsTip": "显示具有部分数据的行。这仍将计算每个桶/级别的指标,即使它们未显示。", "visTypeTable.params.showTotalLabel": "显示汇总", "visTypeTable.params.totalFunctionLabel": "汇总函数", - "visTypeTable.tableVisDescription": "在表中显示值", "visTypeTable.tableVisEditorConfig.schemas.bucketTitle": "拆分行", "visTypeTable.tableVisEditorConfig.schemas.metricTitle": "指标", "visTypeTable.tableVisEditorConfig.schemas.splitTitle": "拆分表", - "visTypeTable.tableVisTitle": "数据表", "visTypeTable.totalAggregations.averageText": "平均值", "visTypeTable.totalAggregations.countText": "计数", "visTypeTable.totalAggregations.maxText": "最大值", @@ -3746,8 +3742,6 @@ "visTypeTagCloud.vis.editorConfig.scales.squareRootText": "平方根", "visTypeTagCloud.vis.schemas.metricTitle": "标记大小", "visTypeTagCloud.vis.schemas.segmentTitle": "标记", - "visTypeTagCloud.vis.tagCloudDescription": "一组字词,可根据其重要性调整大小", - "visTypeTagCloud.vis.tagCloudTitle": "标签云图", "visTypeTagCloud.visParams.fontSizeLabel": "字体大小范围(像素)", "visTypeTagCloud.visParams.orientationsLabel": "方向", "visTypeTagCloud.visParams.showLabelToggleLabel": "显示标签", @@ -4346,7 +4340,6 @@ "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsText": "单个数据源可以返回的最大存储桶数目。较高的数目可能对浏览器呈现性能有负面影响", "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsTitle": "热图最大存储桶数", "visTypeVislib.aggResponse.allDocsTitle": "所有文档", - "visTypeVislib.area.areaDescription": "突出折线图下方的数量", "visTypeVislib.area.areaTitle": "面积图", "visTypeVislib.area.countText": "计数", "visTypeVislib.area.groupTitle": "拆分序列", @@ -4465,26 +4458,19 @@ "visTypeVislib.gauge.gaugeTypes.circleText": "圆形", "visTypeVislib.gauge.groupTitle": "拆分组", "visTypeVislib.gauge.metricTitle": "指标", - "visTypeVislib.goal.goalDescription": "目标图指示与最终目标的接近程度。", "visTypeVislib.goal.goalTitle": "目标图", "visTypeVislib.goal.groupTitle": "拆分组", "visTypeVislib.goal.metricTitle": "指标", "visTypeVislib.heatmap.groupTitle": "Y 轴", - "visTypeVislib.heatmap.heatmapDescription": "为矩阵中的单元格添加阴影", - "visTypeVislib.heatmap.heatmapTitle": "热力图", "visTypeVislib.heatmap.metricTitle": "值", "visTypeVislib.heatmap.segmentTitle": "X 轴", "visTypeVislib.heatmap.splitTitle": "拆分图表", "visTypeVislib.histogram.groupTitle": "拆分序列", - "visTypeVislib.histogram.histogramDescription": "向每个轴赋予连续变量", - "visTypeVislib.histogram.histogramTitle": "垂直条形图", "visTypeVislib.histogram.metricTitle": "Y 轴", "visTypeVislib.histogram.radiusTitle": "点大小", "visTypeVislib.histogram.segmentTitle": "X 轴", "visTypeVislib.histogram.splitTitle": "拆分图表", "visTypeVislib.horizontalBar.groupTitle": "拆分序列", - "visTypeVislib.horizontalBar.horizontalBarDescription": "向每个轴赋予连续变量", - "visTypeVislib.horizontalBar.horizontalBarTitle": "水平条形图", "visTypeVislib.horizontalBar.metricTitle": "Y 轴", "visTypeVislib.horizontalBar.radiusTitle": "点大小", "visTypeVislib.horizontalBar.segmentTitle": "X 轴", @@ -4497,14 +4483,12 @@ "visTypeVislib.legendPositions.rightText": "右", "visTypeVislib.legendPositions.topText": "上", "visTypeVislib.line.groupTitle": "拆分序列", - "visTypeVislib.line.lineDescription": "突出趋势", "visTypeVislib.line.lineTitle": "折线图", "visTypeVislib.line.metricTitle": "Y 轴", "visTypeVislib.line.radiusTitle": "点大小", "visTypeVislib.line.segmentTitle": "X 轴", "visTypeVislib.line.splitTitle": "拆分图表", "visTypeVislib.pie.metricTitle": "切片大小", - "visTypeVislib.pie.pieDescription": "比较整体的各个部分", "visTypeVislib.pie.pieTitle": "饼图", "visTypeVislib.pie.segmentTitle": "拆分切片", "visTypeVislib.pie.splitTitle": "拆分图表", @@ -7287,7 +7271,6 @@ "xpack.fleet.agentPolicyList.pageSubtitle": "使用代理策略管理代理及其收集的数据。", "xpack.fleet.agentPolicyList.pageTitle": "代理策略", "xpack.fleet.agentPolicyList.reloadAgentPoliciesButtonText": "重新加载", - "xpack.fleet.agentPolicyList.revisionNumber": "修订版 {revNumber}", "xpack.fleet.agentPolicyList.updatedOnColumnTitle": "上次更新时间", "xpack.fleet.agentReassignPolicy.cancelButtonLabel": "取消", "xpack.fleet.agentReassignPolicy.continueButtonLabel": "分配策略", @@ -19466,7 +19449,6 @@ "xpack.stackAlerts.indexThreshold.actionVariableContextThresholdLabel": "用作阈值的值数组;“between”和“notBetween”需要两个值,其他则需要一个值。", "xpack.stackAlerts.indexThreshold.actionVariableContextTitleLabel": "告警的预构造标题。", "xpack.stackAlerts.indexThreshold.actionVariableContextValueLabel": "超过阈值的值。", - "xpack.stackAlerts.indexThreshold.alertTypeContextMessageDescription": "告警 {name} 组 {group} 值 {value} 在 {window} 于 {date}超过了阈值 {function}", "xpack.stackAlerts.indexThreshold.alertTypeContextSubjectTitle": "告警 {name} 组 {group} 超过了阈值", "xpack.stackAlerts.indexThreshold.alertTypeTitle": "索引阈值", "xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "指定的 thresholdComparator 无效:{comparator}", diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index 3e5e95996c80f6..28667741801f82 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -1588,15 +1588,6 @@ const connector = { // in render section of component - - ``` ConnectorAddFlyout Props definition: @@ -1695,29 +1687,23 @@ const { http, triggersActionsUi, notifications, application } = useKibana().serv // in render section of component - - + initialConnector={editedConnectorItem} + onClose={onCloseEditFlyout} + reloadConnectors={reloadConnectors} + consumer={'alerts'} + /> ``` ConnectorEditFlyout Props definition: ``` export interface ConnectorEditProps { - initialConnector: ActionConnectorTableItem; - editFlyoutVisible: boolean; - setEditFlyoutVisibility: React.Dispatch>; + initialConnector: ActionConnector; + onClose: () => void; + tab?: EditConectorTabs; + reloadConnectors?: () => Promise; + consumer?: string; } ``` diff --git a/x-pack/plugins/triggers_actions_ui/kibana.json b/x-pack/plugins/triggers_actions_ui/kibana.json index ab2d6c6a3c4003..0487c58e662692 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.json +++ b/x-pack/plugins/triggers_actions_ui/kibana.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "optionalPlugins": ["alerts", "features", "home"], - "requiredPlugins": ["management", "charts", "data", "kibanaReact", "savedObjects"], + "requiredPlugins": ["management", "charts", "data", "kibanaReact", "kibanaUtils", "savedObjects"], "configPath": ["xpack", "trigger_actions_ui"], "extraPublicDirs": ["public/common", "public/common/constants"], "requiredBundles": ["home", "alerts", "esUiShared", "kibanaReact", "kibanaUtils"] diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx index fa38c4501379f6..93614dd191e096 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx @@ -5,21 +5,11 @@ */ import React, { lazy } from 'react'; import { Switch, Route, Redirect, Router } from 'react-router-dom'; -import { - ChromeStart, - DocLinksStart, - ToastsSetup, - HttpSetup, - IUiSettingsClient, - ApplicationStart, - ChromeBreadcrumb, - CoreStart, - ScopedHistory, - SavedObjectsClientContract, -} from 'kibana/public'; +import { ChromeBreadcrumb, CoreStart, ScopedHistory } from 'kibana/public'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; import { KibanaFeature } from '../../../features/common'; import { Section, routeToAlertDetails } from './constants'; -import { AppContextProvider } from './app_context'; import { ActionTypeRegistryContract, AlertTypeRegistryContract } from '../types'; import { ChartsPluginStart } from '../../../../../src/plugins/charts/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; @@ -27,45 +17,47 @@ import { PluginStartContract as AlertingStart } from '../../../alerts/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { setSavedObjectsClient } from '../common/lib/data_apis'; +import { KibanaContextProvider } from '../common/lib/kibana'; + const TriggersActionsUIHome = lazy(async () => import('./home')); const AlertDetailsRoute = lazy( () => import('./sections/alert_details/components/alert_details_route') ); -export interface AppDeps { +export interface TriggersAndActionsUiServices extends CoreStart { data: DataPublicPluginStart; charts: ChartsPluginStart; - chrome: ChromeStart; alerts?: AlertingStart; - navigateToApp: CoreStart['application']['navigateToApp']; - docLinks: DocLinksStart; - toastNotifications: ToastsSetup; storage?: Storage; - http: HttpSetup; - uiSettings: IUiSettingsClient; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; - capabilities: ApplicationStart['capabilities']; actionTypeRegistry: ActionTypeRegistryContract; alertTypeRegistry: AlertTypeRegistryContract; history: ScopedHistory; - savedObjects?: { - client: SavedObjectsClientContract; - }; kibanaFeatures: KibanaFeature[]; + element: HTMLElement; } -export const App = (appDeps: AppDeps) => { +export const renderApp = (deps: TriggersAndActionsUiServices) => { + const { element, savedObjects } = deps; const sections: Section[] = ['alerts', 'connectors']; const sectionsRegex = sections.join('|'); + setSavedObjectsClient(savedObjects.client); - return ( - - - - - + render( + + + + + + + , + element ); + return () => { + unmountComponentAtNode(element); + }; }; export const AppWithoutRouter = ({ sectionsRegex }: { sectionsRegex: string }) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app_context.tsx deleted file mode 100644 index a4568d069c21cc..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/app_context.tsx +++ /dev/null @@ -1,35 +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, { createContext, useContext } from 'react'; -import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; -import { AppDeps } from './app'; - -const AppContext = createContext(null); - -export const AppContextProvider = ({ - children, - appDeps, -}: { - appDeps: AppDeps | null; - children: React.ReactNode; -}) => { - return appDeps ? ( - - {children} - - ) : null; -}; - -export const useAppDependencies = (): AppDeps => { - const ctx = useContext(AppContext); - if (!ctx) { - throw new Error( - 'The app dependencies Context has not been set. Use the "setAppDependencies()" method when bootstrapping the app.' - ); - } - return ctx; -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/boot.tsx b/x-pack/plugins/triggers_actions_ui/public/application/boot.tsx deleted file mode 100644 index e18bf4ce848711..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/boot.tsx +++ /dev/null @@ -1,33 +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 { render, unmountComponentAtNode } from 'react-dom'; -import { App, AppDeps } from './app'; -import { setSavedObjectsClient } from '../common/lib/data_apis'; - -interface BootDeps extends AppDeps { - element: HTMLElement; - I18nContext: any; -} - -export const boot = (bootDeps: BootDeps) => { - const { I18nContext, element, ...appDeps } = bootDeps; - - if (appDeps.savedObjects) { - setSavedObjectsClient(appDeps.savedObjects.client); - } - - render( - - - , - element - ); - return () => { - unmountComponentAtNode(element); - }; -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx index 4fe76b9ff3c2c4..60ee7e4ad2bf38 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx @@ -7,8 +7,8 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { EmailActionConnector } from '../types'; import EmailActionConnectorFields from './email_connector'; -import { DocLinksStart } from 'kibana/public'; +jest.mock('../../../../common/lib/kibana'); describe('EmailActionConnectorFields renders', () => { test('all connector fields is rendered', () => { const actionConnector = { @@ -30,7 +30,6 @@ describe('EmailActionConnectorFields renders', () => { errors={{ from: [], port: [], host: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); @@ -61,7 +60,6 @@ describe('EmailActionConnectorFields renders', () => { errors={{ from: [], port: [], host: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); @@ -89,7 +87,6 @@ describe('EmailActionConnectorFields renders', () => { errors={{ from: [], port: [], host: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); @@ -114,7 +111,6 @@ describe('EmailActionConnectorFields renders', () => { errors={{ from: [], port: [], host: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx index a5f1f257120655..696941e23d4b04 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx @@ -22,10 +22,12 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiLink } from '@elastic/eui'; import { ActionConnectorFieldsProps } from '../../../../types'; import { EmailActionConnector } from '../types'; +import { useKibana } from '../../../../common/lib/kibana'; export const EmailActionConnectorFields: React.FunctionComponent< ActionConnectorFieldsProps -> = ({ action, editActionConfig, editActionSecrets, errors, readOnly, docLinks }) => { +> = ({ action, editActionConfig, editActionSecrets, errors, readOnly }) => { + const { docLinks } = useKibana().services; const { from, host, port, secure, hasAuth } = action.config; const { user, password } = action.secrets; useEffect(() => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx index 1198fc26df80db..3cd54b58bf29ac 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx @@ -5,13 +5,10 @@ */ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; import EmailParamsFields from './email_params'; describe('EmailParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { cc: [], bcc: [], @@ -26,9 +23,6 @@ describe('EmailParamsFields renders', () => { errors={{ to: [], cc: [], bcc: [], subject: [], message: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx index 721cb18e1f360f..4000c92c371cc5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx @@ -7,10 +7,8 @@ import React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { act } from 'react-dom/test-utils'; import { EsIndexActionConnector } from '../types'; -import { coreMock } from '../../../../../../../../src/core/public/mocks'; import IndexActionConnectorFields from './es_index_connector'; -import { TypeRegistry } from '../../../type_registry'; -import { DocLinksStart } from 'kibana/public'; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/index_controls', () => ({ firstFieldOption: jest.fn(), @@ -21,27 +19,6 @@ jest.mock('../../../../common/index_controls', () => ({ describe('IndexActionConnectorFields renders', () => { test('all connector fields is rendered', async () => { - const mocks = coreMock.createSetup(); - const [ - { - application: { capabilities }, - }, - ] = await mocks.getStartServices(); - const deps = { - toastNotifications: mocks.notifications.toasts, - http: mocks.http, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, - }, - actionTypeRegistry: {} as TypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; - const { getIndexPatterns } = jest.requireMock('../../../../common/index_controls'); getIndexPatterns.mockResolvedValueOnce([ { @@ -86,8 +63,6 @@ describe('IndexActionConnectorFields renders', () => { errors={{ index: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - http={deps!.http} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx index ba2f65659cd043..9299bf573b525a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx @@ -26,10 +26,12 @@ import { getIndexOptions, getIndexPatterns, } from '../../../../common/index_controls'; +import { useKibana } from '../../../../common/lib/kibana'; const IndexActionConnectorFields: React.FunctionComponent< ActionConnectorFieldsProps -> = ({ action, editActionConfig, errors, http, readOnly, docLinks }) => { +> = ({ action, editActionConfig, errors, readOnly }) => { + const { http, docLinks } = useKibana().services; const { index, refresh, executionTimeField } = action.config; const [hasTimeFieldCheckbox, setTimeFieldCheckboxState] = useState( executionTimeField != null diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx index 00ec6873044272..e97d1ef2c2e79b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx @@ -6,12 +6,10 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import ParamsFields from './es_index_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; +jest.mock('../../../../common/lib/kibana'); describe('IndexParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { documents: [{ test: 123 }], }; @@ -22,9 +20,6 @@ describe('IndexParamsFields renders', () => { errors={{ index: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="documentsJsonEditor"]').first().prop('value')).toBe(`{ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx index 0a04db1b5ddfa2..fbbd36aa077c06 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -10,15 +10,16 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ActionParamsProps } from '../../../../types'; import { IndexActionParams } from '.././types'; import { JsonEditorWithMessageVariables } from '../../json_editor_with_message_variables'; +import { useKibana } from '../../../../common/lib/kibana'; export const IndexParamsFields = ({ actionParams, index, editAction, messageVariables, - docLinks, errors, }: ActionParamsProps) => { + const { docLinks } = useKibana().services; const { documents } = actionParams; const onDocumentsChange = (updatedDocuments: string) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx index 1bcb528bbd6c76..e3f9bb99b48b63 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { DocLinksStart } from 'kibana/public'; import JiraConnectorFields from './jira_connectors'; import { JiraActionConnector } from './types'; +jest.mock('../../../../common/lib/kibana'); describe('JiraActionConnectorFields renders', () => { test('alerting Jira connector fields is rendered', () => { @@ -25,16 +25,12 @@ describe('JiraActionConnectorFields renders', () => { projectKey: 'CK', }, } as JiraActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -68,16 +64,12 @@ describe('JiraActionConnectorFields renders', () => { projectKey: 'CK', }, } as JiraActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} consumer={'case'} /> @@ -104,16 +96,12 @@ describe('JiraActionConnectorFields renders', () => { secrets: {}, config: {}, } as JiraActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -136,16 +124,12 @@ describe('JiraActionConnectorFields renders', () => { projectKey: 'CK', }, } as JiraActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx index 64ae752afa901e..f32b521797f58e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx @@ -32,7 +32,6 @@ const JiraConnectorFields: React.FC { // TODO: remove incidentConfiguration later, when Case Jira will move their fields to the level of action execution const { apiUrl, projectKey, incidentConfiguration, isCaseOwned } = action.config; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx index 671a575695d69a..89a7c44c60dba4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx @@ -6,18 +6,14 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import JiraParamsFields from './jira_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; - import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import { ActionConnector } from '../../../../types'; +jest.mock('../../../../common/lib/kibana'); jest.mock('./use_get_issue_types'); jest.mock('./use_get_fields_by_issue_type'); -const mocks = coreMock.createSetup(); - const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; @@ -91,9 +87,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[{ name: 'alertId', description: '' }]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -120,9 +113,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -139,9 +129,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -164,9 +151,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -189,9 +173,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -218,9 +199,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -247,9 +225,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index 880e39aada4443..385872ed67bc7c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -29,6 +29,7 @@ import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import { SearchIssues } from './search_issues'; import { extractActionVariable } from '../extract_action_variable'; +import { useKibana } from '../../../../common/lib/kibana'; const JiraParamsFields: React.FunctionComponent> = ({ actionParams, @@ -37,9 +38,11 @@ const JiraParamsFields: React.FunctionComponent { + const { + http, + notifications: { toasts }, + } = useKibana().services; const { title, description, comments, issueType, priority, labels, parent, savedObjectId } = actionParams.subActionParams || {}; @@ -57,13 +60,13 @@ const JiraParamsFields: React.FunctionComponent { editSubActionProperty('parent', parentIssueKey); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx index afd7c429805aca..8481a8931e7b9a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx @@ -8,7 +8,7 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { act } from 'react-dom/test-utils'; import { PagerDutyActionConnector } from '.././types'; import PagerDutyActionConnectorFields from './pagerduty_connectors'; -import { DocLinksStart } from 'kibana/public'; +jest.mock('../../../../common/lib/kibana'); describe('PagerDutyActionConnectorFields renders', () => { test('all connector fields is rendered', async () => { @@ -23,9 +23,6 @@ describe('PagerDutyActionConnectorFields renders', () => { apiUrl: 'http:\\test', }, } as PagerDutyActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( { errors={{ index: [], routingKey: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -56,16 +52,12 @@ describe('PagerDutyActionConnectorFields renders', () => { secrets: {}, config: {}, } as PagerDutyActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -85,16 +77,12 @@ describe('PagerDutyActionConnectorFields renders', () => { apiUrl: 'http:\\test', }, } as PagerDutyActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx index cc2e004d5a1d49..11181196289a37 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx @@ -9,10 +9,12 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; import { PagerDutyActionConnector } from '.././types'; +import { useKibana } from '../../../../common/lib/kibana'; const PagerDutyActionConnectorFields: React.FunctionComponent< ActionConnectorFieldsProps -> = ({ errors, action, editActionConfig, editActionSecrets, docLinks, readOnly }) => { +> = ({ errors, action, editActionConfig, editActionSecrets, readOnly }) => { + const { docLinks } = useKibana().services; const { apiUrl } = action.config; const { routingKey } = action.secrets; return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx index 6236d7a751e009..8b466f1a50a09f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx @@ -7,12 +7,9 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { EventActionOptions, SeverityActionOptions } from '.././types'; import PagerDutyParamsFields from './pagerduty_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; describe('PagerDutyParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { eventAction: EventActionOptions.TRIGGER, dedupKey: 'test', @@ -31,9 +28,6 @@ describe('PagerDutyParamsFields renders', () => { errors={{ summary: [], timestamp: [], dedupKey: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx index 23eebcb4ac0172..a285c962190337 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { DocLinksStart } from 'kibana/public'; import ResilientConnectorFields from './resilient_connectors'; import { ResilientActionConnector } from './types'; +jest.mock('../../../../common/lib/kibana'); describe('ResilientActionConnectorFields renders', () => { test('alerting Resilient connector fields is rendered', () => { @@ -25,16 +25,12 @@ describe('ResilientActionConnectorFields renders', () => { orgId: '201', }, } as ResilientActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -68,16 +64,12 @@ describe('ResilientActionConnectorFields renders', () => { orgId: '201', }, } as ResilientActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} consumer={'case'} /> @@ -105,16 +97,12 @@ describe('ResilientActionConnectorFields renders', () => { config: {}, secrets: {}, } as ResilientActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -137,16 +125,12 @@ describe('ResilientActionConnectorFields renders', () => { orgId: '201', }, } as ResilientActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx index 68626e8a0d3fab..cf7596442a02bb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx @@ -31,7 +31,6 @@ const ResilientConnectorFields: React.FC { // TODO: remove incidentConfiguration later, when Case Resilient will move their fields to the level of action execution const { apiUrl, orgId, incidentConfiguration, isCaseOwned } = action.config; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx index b12307e1fdd161..cb9d96511abd5a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx @@ -6,16 +6,12 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import ResilientParamsFields from './resilient_params'; -import { DocLinksStart } from 'kibana/public'; - import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; -import { coreMock } from 'src/core/public/mocks'; - -const mocks = coreMock.createSetup(); jest.mock('./use_get_incident_types'); jest.mock('./use_get_severity'); +jest.mock('../../../../common/lib/kibana'); const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; @@ -87,9 +83,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[{ name: 'alertId', description: '' }]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -113,9 +106,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -132,9 +122,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -157,9 +144,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -179,9 +163,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -204,9 +185,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx index 996e83b87f0595..194dbe6712446c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -27,6 +27,7 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; import { extractActionVariable } from '../extract_action_variable'; +import { useKibana } from '../../../../common/lib/kibana'; const ResilientParamsFields: React.FunctionComponent> = ({ actionParams, @@ -35,9 +36,11 @@ const ResilientParamsFields: React.FunctionComponent { + const { + http, + notifications: { toasts }, + } = useKibana().services; const [firstLoad, setFirstLoad] = useState(false); const { title, description, comments, incidentTypes, severityCode, savedObjectId } = actionParams.subActionParams || {}; @@ -65,13 +68,13 @@ const ResilientParamsFields: React.FunctionComponent { - const mocks = coreMock.createSetup(); const editAction = jest.fn(); - test('all params fields is rendered', () => { const actionParams = { level: ServerLogLevelOptions.TRACE, @@ -26,9 +22,6 @@ describe('ServerLogParamsFields renders', () => { editAction={editAction} index={0} defaultMessage={'test default message'} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(editAction).not.toHaveBeenCalled(); @@ -50,9 +43,6 @@ describe('ServerLogParamsFields renders', () => { errors={{ message: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 8840045d6b7269..de48e62d88aa18 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { DocLinksStart } from 'kibana/public'; import ServiceNowConnectorFields from './servicenow_connectors'; import { ServiceNowActionConnector } from './types'; +jest.mock('../../../../common/lib/kibana'); describe('ServiceNowActionConnectorFields renders', () => { test('alerting servicenow connector fields is rendered', () => { @@ -24,16 +24,12 @@ describe('ServiceNowActionConnectorFields renders', () => { apiUrl: 'https://test/', }, } as ServiceNowActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -63,16 +59,12 @@ describe('ServiceNowActionConnectorFields renders', () => { isCaseOwned: true, }, } as ServiceNowActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} consumer={'case'} /> @@ -91,16 +83,12 @@ describe('ServiceNowActionConnectorFields renders', () => { config: {}, secrets: {}, } as ServiceNowActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -122,16 +110,12 @@ describe('ServiceNowActionConnectorFields renders', () => { apiUrl: 'https://test/', }, } as ServiceNowActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 06edb22f1c4c95..328667ae49c691 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -26,10 +26,13 @@ import { CasesConfigurationMapping, FieldMapping, createDefaultMapping } from '. import * as i18n from './translations'; import { ServiceNowActionConnector } from './types'; import { connectorConfiguration } from './config'; +import { useKibana } from '../../../../common/lib/kibana'; const ServiceNowConnectorFields: React.FC< ActionConnectorFieldsProps -> = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly, docLinks }) => { +> = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly }) => { + const { docLinks } = useKibana().services; + // TODO: remove incidentConfiguration later, when Case ServiceNow will move their fields to the level of action execution const { apiUrl, incidentConfiguration, isCaseOwned } = action.config; const mapping = incidentConfiguration ? incidentConfiguration.mapping : []; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx index 88f76c760bdcf6..e9d192b472208f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx @@ -6,12 +6,9 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import ServiceNowParamsFields from './servicenow_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; describe('ServiceNowParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { subAction: 'pushToService', subActionParams: { @@ -33,9 +30,6 @@ describe('ServiceNowParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[{ name: 'alertId', description: '' }]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="urgencySelect"]').length > 0).toBeTruthy(); @@ -52,7 +46,6 @@ describe('ServiceNowParamsFields renders', () => { }); test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => { - const mocks = coreMock.createSetup(); const actionParams = { subAction: 'pushToService', subActionParams: { @@ -74,9 +67,6 @@ describe('ServiceNowParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx index 54ed912d635993..f93219265501f7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx @@ -8,7 +8,7 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { act } from '@testing-library/react'; import { SlackActionConnector } from '../types'; import SlackActionFields from './slack_connectors'; -import { DocLinksStart } from 'kibana/public'; +jest.mock('../../../../common/lib/kibana'); describe('SlackActionFields renders', () => { test('all connector fields is rendered', async () => { @@ -21,16 +21,12 @@ describe('SlackActionFields renders', () => { name: 'email', config: {}, } as SlackActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -51,16 +47,12 @@ describe('SlackActionFields renders', () => { config: {}, secrets: {}, } as SlackActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -78,16 +70,12 @@ describe('SlackActionFields renders', () => { name: 'email', config: {}, } as SlackActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx index d146e0c7a009d5..714b27178d3cf1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx @@ -9,10 +9,12 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; import { SlackActionConnector } from '../types'; +import { useKibana } from '../../../../common/lib/kibana'; const SlackActionFields: React.FunctionComponent< ActionConnectorFieldsProps -> = ({ action, editActionSecrets, errors, readOnly, docLinks }) => { +> = ({ action, editActionSecrets, errors, readOnly }) => { + const { docLinks } = useKibana().services; const { webhookUrl } = action.secrets; return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx index cf356777212253..afaa7ae2bc71dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx @@ -6,12 +6,9 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import SlackParamsFields from './slack_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; describe('SlackParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { message: 'test message', }; @@ -22,9 +19,6 @@ describe('SlackParamsFields renders', () => { errors={{ message: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="messageTextArea"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx index eaa7159db6a3d5..69076827137b40 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx @@ -8,7 +8,7 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { act } from '@testing-library/react'; import { TeamsActionConnector } from '../types'; import TeamsActionFields from './teams_connectors'; -import { DocLinksStart } from 'kibana/public'; +jest.mock('../../../../common/lib/kibana'); describe('TeamsActionFields renders', () => { test('all connector fields are rendered', async () => { @@ -21,16 +21,12 @@ describe('TeamsActionFields renders', () => { name: 'teams', config: {}, } as TeamsActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -51,16 +47,12 @@ describe('TeamsActionFields renders', () => { config: {}, secrets: {}, } as TeamsActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -78,16 +70,12 @@ describe('TeamsActionFields renders', () => { name: 'teams', config: {}, } as TeamsActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx index 7de0df33297965..4e61bffa5ade39 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx @@ -9,11 +9,13 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; import { TeamsActionConnector } from '../types'; +import { useKibana } from '../../../../common/lib/kibana'; const TeamsActionFields: React.FunctionComponent< ActionConnectorFieldsProps -> = ({ action, editActionSecrets, errors, readOnly, docLinks }) => { +> = ({ action, editActionSecrets, errors, readOnly }) => { const { webhookUrl } = action.secrets; + const { docLinks } = useKibana().services; return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.test.tsx index 02ad3e33a28e0b..f82ff2cf47de7b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.test.tsx @@ -6,12 +6,10 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import TeamsParamsFields from './teams_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; +jest.mock('../../../../common/lib/kibana'); describe('TeamsParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { message: 'test message', }; @@ -22,9 +20,6 @@ describe('TeamsParamsFields renders', () => { errors={{ message: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="messageTextArea"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx index a6a06287b73f3d..b83a904c2477e0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { WebhookActionConnector } from '../types'; import WebhookActionConnectorFields from './webhook_connectors'; -import { DocLinksStart } from 'kibana/public'; describe('WebhookActionConnectorFields renders', () => { test('all connector fields is rendered', () => { @@ -33,7 +32,6 @@ describe('WebhookActionConnectorFields renders', () => { errors={{ url: [], method: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); @@ -61,7 +59,6 @@ describe('WebhookActionConnectorFields renders', () => { errors={{ url: [], method: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); @@ -92,7 +89,6 @@ describe('WebhookActionConnectorFields renders', () => { errors={{ url: [], method: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx index 7dafea8a99e876..3b645f33bdde45 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx @@ -6,12 +6,9 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import WebhookParamsFields from './webhook_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; describe('WebhookParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { body: 'test message', }; @@ -22,9 +19,6 @@ describe('WebhookParamsFields renders', () => { errors={{ body: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="bodyJsonEditor"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx index a093b9c5119706..0220bcaf7cd976 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx @@ -7,7 +7,7 @@ import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; import { HttpSetup } from 'kibana/public'; -import { useAppDependencies } from '../app_context'; +import { useKibana } from '../../common/lib/kibana'; export const DeleteModalConfirmation = ({ idsToDelete, @@ -40,7 +40,10 @@ export const DeleteModalConfirmation = ({ setDeleteModalVisibility(idsToDelete.length > 0); }, [idsToDelete]); - const { http, toastNotifications } = useAppDependencies(); + const { + http, + notifications: { toasts }, + } = useKibana().services; const numIdsToDelete = idsToDelete.length; if (!deleteModalFlyoutVisible) { return null; @@ -86,7 +89,7 @@ export const DeleteModalConfirmation = ({ const numSuccesses = successes.length; const numErrors = errors.length; if (numSuccesses > 0) { - toastNotifications.addSuccess( + toasts.addSuccess( i18n.translate( 'xpack.triggersActionsUI.components.deleteSelectedIdsSuccessNotification.descriptionText', { @@ -99,7 +102,7 @@ export const DeleteModalConfirmation = ({ } if (numErrors > 0) { - toastNotifications.addDanger( + toasts.addDanger( i18n.translate( 'xpack.triggersActionsUI.components.deleteSelectedIdsErrorNotification.descriptionText', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx deleted file mode 100644 index bb0606db2a9b3d..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { createContext, useContext } from 'react'; -import { HttpSetup, ApplicationStart, DocLinksStart, ToastsSetup } from 'kibana/public'; -import { ActionTypeRegistryContract, ActionConnector } from '../../types'; - -export interface ActionsConnectorsContextValue { - http: HttpSetup; - actionTypeRegistry: ActionTypeRegistryContract; - toastNotifications: ToastsSetup; - capabilities: ApplicationStart['capabilities']; - reloadConnectors?: () => Promise; - docLinks: DocLinksStart; - consumer?: string; -} - -const ActionsConnectorsContext = createContext(null as any); - -export const ActionsConnectorsContextProvider = ({ - children, - value, -}: { - value: ActionsConnectorsContextValue; - children: React.ReactNode; -}) => { - return ( - {children} - ); -}; - -export const useActionsConnectorsContext = () => { - const ctx = useContext(ActionsConnectorsContext); - if (!ctx) { - throw new Error('ActionsConnectorsContext has not been set.'); - } - return ctx; -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/home.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/home.test.tsx index 80cebeb055721d..435be117b51339 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/home.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/home.test.tsx @@ -8,15 +8,13 @@ import * as React from 'react'; import { RouteComponentProps, Router } from 'react-router-dom'; import { createMemoryHistory, createLocation } from 'history'; import { mountWithIntl } from '@kbn/test/jest'; - import TriggersActionsUIHome, { MatchParams } from './home'; -import { AppContextProvider } from './app_context'; -import { getMockedAppDependencies } from './test_utils'; +import { useKibana } from '../common/lib/kibana'; +jest.mock('../common/lib/kibana'); +const useKibanaMock = useKibana as jest.Mocked; describe('home', () => { it('renders the documentation link', async () => { - const deps = await getMockedAppDependencies(); - const props: RouteComponentProps = { history: createMemoryHistory(), location: createLocation('/'), @@ -29,11 +27,10 @@ describe('home', () => { }, }, }; + const wrapper = mountWithIntl( - - - - + + ); const documentationLink = wrapper.find('[data-test-subj="documentationLink"]'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx index 450f33d4f7e892..97faef6d49963e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx @@ -23,13 +23,13 @@ import { import { Section, routeToConnectors, routeToAlerts } from './constants'; import { getAlertingSectionBreadcrumb } from './lib/breadcrumb'; import { getCurrentDocTitle } from './lib/doc_title'; -import { useAppDependencies } from './app_context'; import { hasShowActionsCapability } from './lib/capabilities'; import { ActionsConnectorsList } from './sections/actions_connectors_list/components/actions_connectors_list'; import { AlertsList } from './sections/alerts_list/components/alerts_list'; import { HealthCheck } from './components/health_check'; import { HealthContextProvider } from './context/health_context'; +import { useKibana } from '../common/lib/kibana'; export interface MatchParams { section: Section; @@ -41,7 +41,13 @@ export const TriggersActionsUIHome: React.FunctionComponent { - const { chrome, capabilities, setBreadcrumbs, docLinks, http } = useAppDependencies(); + const { + chrome, + application: { capabilities }, + setBreadcrumbs, + docLinks, + http, + } = useKibana().services; const canShowActions = hasShowActionsCapability(capabilities); const tabs: Array<{ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index cc42c6296e7b30..f6164b1856bb51 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -5,29 +5,13 @@ */ import * as React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, UserConfiguredActionConnector } from '../../../types'; import { ActionConnectorForm } from './action_connector_form'; const actionTypeRegistry = actionTypeRegistryMock.create(); +jest.mock('../../../common/lib/kibana'); describe('action_connector_form', () => { - let deps: any; - beforeAll(async () => { - const mocks = coreMock.createSetup(); - const [ - { - application: { capabilities }, - }, - ] = await mocks.getStartServices(); - deps = { - http: mocks.http, - actionTypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - capabilities, - }; - }); - it('renders action_connector_form', () => { const actionType = { id: 'my-action-type', @@ -54,21 +38,15 @@ describe('action_connector_form', () => { secrets: {}, isPreconfigured: false, }; - let wrapper; - if (deps) { - wrapper = mountWithIntl( - {}} - errors={{ name: [] }} - http={deps!.http} - actionTypeRegistry={deps!.actionTypeRegistry} - docLinks={deps!.docLinks} - capabilities={deps!.capabilities} - /> - ); - } + const wrapper = mountWithIntl( + {}} + errors={{ name: [] }} + actionTypeRegistry={actionTypeRegistry} + /> + ); const connectorNameField = wrapper?.find('[data-test-subj="nameInput"]'); expect(connectorNameField?.exists()).toBeTruthy(); expect(connectorNameField?.first().prop('value')).toBe(''); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index 53121e5249abff..7d8949421126c9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -20,7 +20,6 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { HttpSetup, ApplicationStart, DocLinksStart } from 'kibana/public'; import { ReducerAction } from './connector_reducer'; import { ActionConnector, @@ -29,6 +28,7 @@ import { UserConfiguredActionConnector, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { useKibana } from '../../../common/lib/kibana'; export function validateBaseProperties(actionObject: ActionConnector) { const validationResult = { errors: {} }; @@ -60,10 +60,7 @@ interface ActionConnectorProps< body: { message: string; error: string }; }; errors: IErrorObject; - http: HttpSetup; actionTypeRegistry: ActionTypeRegistryContract; - docLinks: DocLinksStart; - capabilities: ApplicationStart['capabilities']; consumer?: string; } @@ -73,12 +70,13 @@ export const ActionConnectorForm = ({ actionTypeName, serverError, errors, - http, actionTypeRegistry, - docLinks, - capabilities, consumer, }: ActionConnectorProps) => { + const { + docLinks, + application: { capabilities }, + } = useKibana().services; const canSave = hasSaveActionsCapability(capabilities); const setActionProperty = (key: string, value: any) => { @@ -196,8 +194,6 @@ export const ActionConnectorForm = ({ readOnly={!canSave} editActionConfig={setActionConfigProperty} editActionSecrets={setActionSecretsProperty} - http={http} - docLinks={docLinks} consumer={consumer} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 38c9687ae581e8..5b56720737b7e1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -11,14 +11,14 @@ import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, Alert, AlertAction } from '../../../types'; import ActionForm from './action_form'; import { ResolvedActionGroup } from '../../../../../alerts/common'; +import { useKibana } from '../../../common/lib/kibana'; +jest.mock('../../../common/lib/kibana'); jest.mock('../../lib/action_connector_api', () => ({ loadAllActions: jest.fn(), loadActionTypes: jest.fn(), })); -const actionTypeRegistry = actionTypeRegistryMock.create(); +const setHasActionsWithBrokenConnector = jest.fn(); describe('action_form', () => { - let deps: any; - const mockedActionParamsFields = lazy(async () => ({ default() { return ; @@ -110,9 +110,12 @@ describe('action_form', () => { actionConnectorFields: null, actionParamsFields: null, }; + const useKibanaMock = useKibana as jest.Mocked; describe('action_form in alert', () => { async function setup(customActions?: AlertAction[]) { + const actionTypeRegistry = actionTypeRegistryMock.create(); + const { loadAllActions } = jest.requireMock('../../lib/action_connector_api'); loadAllActions.mockResolvedValueOnce([ { @@ -164,20 +167,14 @@ describe('action_form', () => { application: { capabilities }, }, ] = await mocks.getStartServices(); - deps = { - toastNotifications: mocks.notifications.toasts, - http: mocks.http, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: true, + delete: true, }, - setHasActionsWithBrokenConnector: jest.fn(), - actionTypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; actionTypeRegistry.list.mockReturnValue([ actionType, @@ -188,7 +185,6 @@ describe('action_form', () => { ]); actionTypeRegistry.has.mockReturnValue(true); actionTypeRegistry.get.mockReturnValue(actionType); - const initialAlert = ({ name: 'test', params: {}, @@ -241,9 +237,8 @@ describe('action_form', () => { setActionParamsProperty={(key: string, value: any, index: number) => (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) } - setHasActionsWithBrokenConnector={deps!.setHasActionsWithBrokenConnector} - http={deps!.http} - actionTypeRegistry={deps!.actionTypeRegistry} + actionTypeRegistry={actionTypeRegistry} + setHasActionsWithBrokenConnector={setHasActionsWithBrokenConnector} defaultActionMessage={'Alert [{{ctx.metadata.name}}] has exceeded the threshold'} actionTypes={[ { @@ -295,9 +290,6 @@ describe('action_form', () => { minimumLicenseRequired: 'basic', }, ]} - toastNotifications={deps!.toastNotifications} - docLinks={deps.docLinks} - capabilities={deps.capabilities} /> ); @@ -321,7 +313,7 @@ describe('action_form', () => { .find(`EuiToolTip [data-test-subj="${actionType.id}-ActionTypeSelectOption"]`) .exists() ).toBeFalsy(); - expect(deps.setHasActionsWithBrokenConnector).toHaveBeenLastCalledWith(false); + expect(setHasActionsWithBrokenConnector).toHaveBeenLastCalledWith(false); }); it('does not render action types disabled by config', async () => { @@ -490,7 +482,7 @@ describe('action_form', () => { }, }, ]); - expect(deps.setHasActionsWithBrokenConnector).toHaveBeenLastCalledWith(true); + expect(setHasActionsWithBrokenConnector).toHaveBeenLastCalledWith(true); }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 83e6386122eb2b..d62b8e7694089d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -18,16 +18,15 @@ import { EuiToolTip, EuiLink, } from '@elastic/eui'; -import { HttpSetup, ToastsSetup, ApplicationStart, DocLinksStart } from 'kibana/public'; import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api'; import { ActionTypeModel, - ActionTypeRegistryContract, AlertAction, ActionTypeIndex, ActionConnector, ActionType, ActionVariables, + ActionTypeRegistryContract, } from '../../../types'; import { SectionLoading } from '../../components/section_loading'; import { ConnectorAddModal } from './connector_add_modal'; @@ -37,6 +36,7 @@ import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants'; import { ActionGroup, AlertActionParam } from '../../../../../alerts/common'; +import { useKibana } from '../../../common/lib/kibana'; export interface ActionAccordionFormProps { actions: AlertAction[]; @@ -46,16 +46,12 @@ export interface ActionAccordionFormProps { setActionGroupIdByIndex?: (group: string, index: number) => void; setAlertProperty: (actions: AlertAction[]) => void; setActionParamsProperty: (key: string, value: AlertActionParam, index: number) => void; - http: HttpSetup; - actionTypeRegistry: ActionTypeRegistryContract; - toastNotifications: ToastsSetup; - docLinks: DocLinksStart; actionTypes?: ActionType[]; messageVariables?: ActionVariables; defaultActionMessage?: string; setHasActionsDisabled?: (value: boolean) => void; setHasActionsWithBrokenConnector?: (value: boolean) => void; - capabilities: ApplicationStart['capabilities']; + actionTypeRegistry: ActionTypeRegistryContract; } interface ActiveActionConnectorState { @@ -71,17 +67,17 @@ export const ActionForm = ({ setActionGroupIdByIndex, setAlertProperty, setActionParamsProperty, - http, - actionTypeRegistry, actionTypes, messageVariables, defaultActionMessage, - toastNotifications, setHasActionsDisabled, setHasActionsWithBrokenConnector, - capabilities, - docLinks, + actionTypeRegistry, }: ActionAccordionFormProps) => { + const { + http, + notifications: { toasts }, + } = useKibana().services; const [addModalVisible, setAddModalVisibility] = useState(false); const [activeActionItem, setActiveActionItem] = useState( undefined @@ -111,7 +107,7 @@ export const ActionForm = ({ } setActionTypesIndex(index); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionTypesMessage', { defaultMessage: 'Unable to load action types' } @@ -132,7 +128,7 @@ export const ActionForm = ({ const loadedConnectors = await loadConnectors({ http }); setConnectors(loadedConnectors); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage', { @@ -181,7 +177,7 @@ export const ActionForm = ({ function addActionType(actionTypeModel: ActionTypeModel) { if (!defaultActionGroupId) { - toastNotifications!.addDanger({ + toasts!.addDanger({ title: i18n.translate('xpack.triggersActionsUI.sections.alertForm.unableToAddAction', { defaultMessage: 'Unable to add action, because default action group is not defined', }), @@ -302,7 +298,6 @@ export const ActionForm = ({ key={`action-form-action-at-${index}`} actionTypeRegistry={actionTypeRegistry} defaultActionGroupId={defaultActionGroupId} - capabilities={capabilities} emptyActionsIds={emptyActionsIds} onDeleteConnector={() => { const updatedActions = actions.filter( @@ -337,11 +332,6 @@ export const ActionForm = ({ setActionParamsProperty={setActionParamsProperty} actionTypesIndex={actionTypesIndex} connectors={connectors} - http={http} - toastNotifications={toastNotifications} - docLinks={docLinks} - capabilities={capabilities} - actionTypeRegistry={actionTypeRegistry} defaultActionGroupId={defaultActionGroupId} defaultActionMessage={defaultActionMessage} messageVariables={messageVariables} @@ -354,6 +344,7 @@ export const ActionForm = ({ onConnectorSelected={(id: string) => { setActionIdByIndex(id, index); }} + actionTypeRegistry={actionTypeRegistry} onDeleteAction={() => { const updatedActions = actions.filter( (_item: AlertAction, i: number) => i !== index @@ -442,10 +433,6 @@ export const ActionForm = ({ setActionIdByIndex(savedAction.id, activeActionItem.index); }} actionTypeRegistry={actionTypeRegistry} - http={http} - toastNotifications={toastNotifications} - docLinks={docLinks} - capabilities={capabilities} /> ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 10c8498b181dca..bd0e4b1645319c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -34,12 +34,14 @@ import { ActionConnector, ActionVariables, ActionVariable, + ActionTypeRegistryContract, } from '../../../types'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { ActionAccordionFormProps } from './action_form'; import { transformActionVariables } from '../../lib/action_variables'; import { resolvedActionGroupMessage } from '../../constants'; +import { useKibana } from '../../../common/lib/kibana'; export type ActionTypeFormProps = { actionItem: AlertAction; @@ -54,19 +56,15 @@ export type ActionTypeFormProps = { setActionParamsProperty: (key: string, value: AlertActionParam, index: number) => void; actionTypesIndex: ActionTypeIndex; connectors: ActionConnector[]; + actionTypeRegistry: ActionTypeRegistryContract; } & Pick< ActionAccordionFormProps, | 'defaultActionGroupId' | 'actionGroups' | 'setActionGroupIdByIndex' | 'setActionParamsProperty' - | 'http' - | 'actionTypeRegistry' - | 'toastNotifications' - | 'docLinks' | 'messageVariables' | 'defaultActionMessage' - | 'capabilities' >; const preconfiguredMessage = i18n.translate( @@ -87,17 +85,16 @@ export const ActionTypeForm = ({ setActionParamsProperty, actionTypesIndex, connectors, - http, - toastNotifications, - docLinks, - capabilities, - actionTypeRegistry, defaultActionGroupId, defaultActionMessage, messageVariables, actionGroups, setActionGroupIdByIndex, + actionTypeRegistry, }: ActionTypeFormProps) => { + const { + application: { capabilities }, + } = useKibana().services; const [isOpen, setIsOpen] = useState(true); const [availableActionVariables, setAvailableActionVariables] = useState([]); const [availableDefaultActionMessage, setAvailableDefaultActionMessage] = useState< @@ -272,9 +269,6 @@ export const ActionTypeForm = ({ editAction={setActionParamsProperty} messageVariables={availableActionVariables} defaultMessage={availableDefaultActionMessage} - docLinks={docLinks} - http={http} - toastNotifications={toastNotifications} actionConnector={actionConnector} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index 8102b4d393a196..2343ea1036ed91 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -6,15 +6,15 @@ import * as React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ActionTypeMenu } from './action_type_menu'; import { ValidationResult } from '../../../types'; +import { useKibana } from '../../../common/lib/kibana'; +jest.mock('../../../common/lib/kibana'); const actionTypeRegistry = actionTypeRegistryMock.create(); +const useKibanaMock = useKibana as jest.Mocked; describe('connector_add_flyout', () => { - let deps: any; - beforeAll(async () => { const mockes = coreMock.createSetup(); const [ @@ -22,19 +22,13 @@ describe('connector_add_flyout', () => { application: { capabilities }, }, ] = await mockes.getStartServices(); - deps = { - http: mockes.http, - toastNotifications: mockes.notifications.toasts, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: true, + delete: true, }, - actionTypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); @@ -57,32 +51,20 @@ describe('connector_add_flyout', () => { actionTypeRegistry.get.mockReturnValueOnce(actionType); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + - - + ]} + actionTypeRegistry={actionTypeRegistry} + /> ); expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); @@ -107,32 +89,20 @@ describe('connector_add_flyout', () => { actionTypeRegistry.get.mockReturnValueOnce(actionType); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + - - + ]} + actionTypeRegistry={actionTypeRegistry} + /> ); expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeFalsy(); @@ -157,32 +127,20 @@ describe('connector_add_flyout', () => { actionTypeRegistry.get.mockReturnValueOnce(actionType); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + - - + ]} + actionTypeRegistry={actionTypeRegistry} + /> ); expect(wrapper.find('EuiToolTip [data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index 7ecb833fdfc9ef..7cd95c92b22a3c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -7,24 +7,29 @@ import React, { useEffect, useState } from 'react'; import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { EuiToolTip } from '@elastic/eui'; -import { ActionType, ActionTypeIndex } from '../../../types'; +import { ActionType, ActionTypeIndex, ActionTypeRegistryContract } from '../../../types'; import { loadActionTypes } from '../../lib/action_connector_api'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled'; +import { useKibana } from '../../../common/lib/kibana'; interface Props { onActionTypeChange: (actionType: ActionType) => void; actionTypes?: ActionType[]; setHasActionsUpgradeableByTrial?: (value: boolean) => void; + actionTypeRegistry: ActionTypeRegistryContract; } export const ActionTypeMenu = ({ onActionTypeChange, actionTypes, setHasActionsUpgradeableByTrial, + actionTypeRegistry, }: Props) => { - const { http, toastNotifications, actionTypeRegistry } = useActionsConnectorsContext(); + const { + http, + notifications: { toasts }, + } = useKibana().services; const [actionTypesIndex, setActionTypesIndex] = useState(undefined); useEffect(() => { @@ -47,8 +52,8 @@ export const ActionTypeMenu = ({ setHasActionsUpgradeableByTrial(hasActionsUpgradeableByTrial); } } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ + if (toasts) { + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionTypesMessage', { defaultMessage: 'Unable to load action types' } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx index 68d5d1e7d95733..4ffb97f0191526 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -7,15 +7,15 @@ import * as React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import ConnectorAddFlyout from './connector_add_flyout'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; +import { useKibana } from '../../../common/lib/kibana'; +jest.mock('../../../common/lib/kibana'); const actionTypeRegistry = actionTypeRegistryMock.create(); +const useKibanaMock = useKibana as jest.Mocked; describe('connector_add_flyout', () => { - let deps: any; - beforeAll(async () => { const mocks = coreMock.createSetup(); const [ @@ -23,19 +23,13 @@ describe('connector_add_flyout', () => { application: { capabilities }, }, ] = await mocks.getStartServices(); - deps = { - toastNotifications: mocks.notifications.toasts, - http: mocks.http, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: true, + delete: true, }, - actionTypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); @@ -45,32 +39,23 @@ describe('connector_add_flyout', () => { actionTypeRegistry.has.mockReturnValue(true); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + {}} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', }, - docLinks: deps!.docLinks, + ]} + reloadConnectors={() => { + return new Promise(() => {}); }} - > - {}} - actionTypes={[ - { - id: actionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - ]} - /> - + actionTypeRegistry={actionTypeRegistry} + /> ); expect(wrapper.find('ActionTypeMenu')).toHaveLength(1); expect(wrapper.find(`[data-test-subj="${actionType.id}-card"]`).exists()).toBeTruthy(); @@ -86,40 +71,31 @@ describe('connector_add_flyout', () => { actionTypeRegistry.has.mockReturnValue(true); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + {}} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + { + id: disabledActionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: false, + minimumLicenseRequired: 'gold', }, - docLinks: deps!.docLinks, + ]} + reloadConnectors={() => { + return new Promise(() => {}); }} - > - {}} - actionTypes={[ - { - id: actionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - { - id: disabledActionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: false, - minimumLicenseRequired: 'gold', - }, - ]} - /> - + actionTypeRegistry={actionTypeRegistry} + /> ); const callout = wrapper.find('UpgradeYourLicenseCallOut'); expect(callout).toHaveLength(1); @@ -145,40 +121,31 @@ describe('connector_add_flyout', () => { actionTypeRegistry.has.mockReturnValue(true); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + {}} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', }, - docLinks: deps!.docLinks, + { + id: disabledActionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: false, + minimumLicenseRequired: 'platinum', + }, + ]} + reloadConnectors={() => { + return new Promise(() => {}); }} - > - {}} - actionTypes={[ - { - id: actionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - { - id: disabledActionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: false, - minimumLicenseRequired: 'platinum', - }, - ]} - /> - + actionTypeRegistry={actionTypeRegistry} + /> ); const callout = wrapper.find('UpgradeYourLicenseCallOut'); expect(callout).toHaveLength(0); @@ -192,40 +159,31 @@ describe('connector_add_flyout', () => { actionTypeRegistry.has.mockReturnValue(true); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + {}} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + { + id: disabledActionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: false, + minimumLicenseRequired: 'enterprise', }, - docLinks: deps!.docLinks, + ]} + reloadConnectors={() => { + return new Promise(() => {}); }} - > - {}} - actionTypes={[ - { - id: actionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - { - id: disabledActionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: false, - minimumLicenseRequired: 'enterprise', - }, - ]} - /> - + actionTypeRegistry={actionTypeRegistry} + /> ); const callout = wrapper.find('UpgradeYourLicenseCallOut'); expect(callout).toHaveLength(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index b53d0816ea0688..618f59726f38bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -24,34 +24,41 @@ import { HttpSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { ActionTypeMenu } from './action_type_menu'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; -import { ActionType, ActionConnector, IErrorObject } from '../../../types'; +import { + ActionType, + ActionConnector, + IErrorObject, + ActionTypeRegistryContract, +} from '../../../types'; import { connectorReducer } from './connector_reducer'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; +import { useKibana } from '../../../common/lib/kibana'; export interface ConnectorAddFlyoutProps { onClose: () => void; actionTypes?: ActionType[]; onTestConnector?: (connector: ActionConnector) => void; + reloadConnectors?: () => Promise; + consumer?: string; + actionTypeRegistry: ActionTypeRegistryContract; } -export const ConnectorAddFlyout = ({ +const ConnectorAddFlyout: React.FunctionComponent = ({ onClose, actionTypes, onTestConnector, -}: ConnectorAddFlyoutProps) => { + reloadConnectors, + consumer, + actionTypeRegistry, +}) => { let hasErrors = false; const { http, - toastNotifications, - capabilities, - actionTypeRegistry, - reloadConnectors, - docLinks, - consumer, - } = useActionsConnectorsContext(); + notifications: { toasts }, + application: { capabilities }, + } = useKibana().services; const [actionType, setActionType] = useState(undefined); const [hasActionsUpgradeableByTrial, setHasActionsUpgradeableByTrial] = useState(false); @@ -90,6 +97,7 @@ export const ConnectorAddFlyout = ({ onActionTypeChange={onActionTypeChange} actionTypes={actionTypes} setHasActionsUpgradeableByTrial={setHasActionsUpgradeableByTrial} + actionTypeRegistry={actionTypeRegistry} /> ); } else { @@ -108,19 +116,15 @@ export const ConnectorAddFlyout = ({ dispatch={dispatch} errors={errors} actionTypeRegistry={actionTypeRegistry} - http={http} - docLinks={docLinks} - capabilities={capabilities} consumer={consumer} /> ); } - const onActionConnectorSave = async (): Promise => await createActionConnector({ http, connector }) .then((savedConnector) => { - if (toastNotifications) { - toastNotifications.addSuccess( + if (toasts) { + toasts.addSuccess( i18n.translate( 'xpack.triggersActionsUI.sections.addConnectorForm.updateSuccessNotificationText', { @@ -135,7 +139,7 @@ export const ConnectorAddFlyout = ({ return savedConnector; }) .catch((errorRes) => { - toastNotifications.addDanger( + toasts.addDanger( errorRes.body?.message ?? i18n.translate( 'xpack.triggersActionsUI.sections.addConnectorForm.updateErrorNotificationText', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx index 97baf4a36cb4cc..95b8741c17a7a4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx @@ -22,6 +22,7 @@ import { import { AlertAction, ActionTypeIndex } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { ActionAccordionFormProps } from './action_form'; +import { useKibana } from '../../../common/lib/kibana'; type AddConnectorInFormProps = { actionTypesIndex: ActionTypeIndex; @@ -30,7 +31,7 @@ type AddConnectorInFormProps = { onAddConnector: () => void; onDeleteConnector: () => void; emptyActionsIds: string[]; -} & Pick; +} & Pick; export const AddConnectorInline = ({ actionTypesIndex, @@ -41,8 +42,10 @@ export const AddConnectorInline = ({ actionTypeRegistry, emptyActionsIds, defaultActionGroupId, - capabilities, }: AddConnectorInFormProps) => { + const { + application: { capabilities }, + } = useKibana().services; const canSave = hasSaveActionsCapability(capabilities); const actionTypeName = actionTypesIndex diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index 0d634729002eb5..31e4fdd4f4507e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -5,35 +5,31 @@ */ import * as React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; import { ConnectorAddModal } from './connector_add_modal'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, ActionType } from '../../../types'; +import { useKibana } from '../../../common/lib/kibana'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; + +jest.mock('../../../common/lib/kibana'); +const mocks = coreMock.createSetup(); const actionTypeRegistry = actionTypeRegistryMock.create(); +const useKibanaMock = useKibana as jest.Mocked; describe('connector_add_modal', () => { - let deps: any; - beforeAll(async () => { - const mocks = coreMock.createSetup(); const [ { application: { capabilities }, }, ] = await mocks.getStartServices(); - deps = { - toastNotifications: mocks.notifications.toasts, - http: mocks.http, - capabilities: { - ...capabilities, - actions: { - show: true, - save: true, - delete: true, - }, + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: true, + delete: true, }, - actionTypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); it('renders connector modal form if addModalVisible is true', () => { @@ -67,11 +63,7 @@ describe('connector_add_modal', () => { {}} actionType={actionType} - http={deps!.http} - actionTypeRegistry={deps!.actionTypeRegistry} - toastNotifications={deps!.toastNotifications} - docLinks={deps!.docLinks} - capabilities={deps!.capabilities} + actionTypeRegistry={actionTypeRegistry} /> ); expect(wrapper.exists('.euiModalHeader')).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index a2a2d1234dbcd6..d63dd5d2985be0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useReducer, useState } from 'react'; +import React, { useCallback, useMemo, useReducer, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, EuiFlexItem, EuiIcon, EuiFlexGroup } from '@elastic/eui'; import { @@ -17,7 +17,6 @@ import { import { EuiButtonEmpty } from '@elastic/eui'; import { EuiOverlayMask } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { HttpSetup, ToastsApi, ApplicationStart, DocLinksStart } from 'kibana/public'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; import { connectorReducer } from './connector_reducer'; import { createActionConnector } from '../../lib/action_connector_api'; @@ -29,40 +28,37 @@ import { IErrorObject, ActionTypeRegistryContract, } from '../../../types'; +import { useKibana } from '../../../common/lib/kibana'; interface ConnectorAddModalProps { actionType: ActionType; onClose: () => void; postSaveEventHandler?: (savedAction: ActionConnector) => void; - http: HttpSetup; - actionTypeRegistry: ActionTypeRegistryContract; - toastNotifications: Pick< - ToastsApi, - 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' - >; - capabilities: ApplicationStart['capabilities']; - docLinks: DocLinksStart; consumer?: string; + actionTypeRegistry: ActionTypeRegistryContract; } export const ConnectorAddModal = ({ actionType, onClose, postSaveEventHandler, - http, - toastNotifications, - actionTypeRegistry, - capabilities, - docLinks, consumer, + actionTypeRegistry, }: ConnectorAddModalProps) => { + const { + http, + notifications: { toasts }, + application: { capabilities }, + } = useKibana().services; let hasErrors = false; - // eslint-disable-next-line react-hooks/exhaustive-deps - const initialConnector = { - actionTypeId: actionType.id, - config: {}, - secrets: {}, - } as ActionConnector; + const initialConnector = useMemo( + () => ({ + actionTypeId: actionType.id, + config: {}, + secrets: {}, + }), + [actionType.id] + ); const [isSaving, setIsSaving] = useState(false); const canSave = hasSaveActionsCapability(capabilities); @@ -93,8 +89,8 @@ export const ConnectorAddModal = ({ const onActionConnectorSave = async (): Promise => await createActionConnector({ http, connector }) .then((savedConnector) => { - if (toastNotifications) { - toastNotifications.addSuccess( + if (toasts) { + toasts.addSuccess( i18n.translate( 'xpack.triggersActionsUI.sections.addModalConnectorForm.updateSuccessNotificationText', { @@ -149,9 +145,6 @@ export const ConnectorAddModal = ({ serverError={serverError} errors={errors} actionTypeRegistry={actionTypeRegistry} - docLinks={docLinks} - http={http} - capabilities={capabilities} consumer={consumer} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx index 5833c3a9a37bf1..581db16e9a1362 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -6,14 +6,14 @@ import * as React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; import ConnectorEditFlyout from './connector_edit_flyout'; -import { AppContextProvider } from '../../app_context'; +import { useKibana } from '../../../common/lib/kibana'; +jest.mock('../../../common/lib/kibana'); const actionTypeRegistry = actionTypeRegistryMock.create(); -let deps: any; +const useKibanaMock = useKibana as jest.Mocked; describe('connector_edit_flyout', () => { beforeAll(async () => { @@ -23,21 +23,13 @@ describe('connector_edit_flyout', () => { application: { capabilities }, }, ] = await mockes.getStartServices(); - deps = { - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: false, + delete: false, }, - actionTypeRegistry, - alertTypeRegistry: {} as any, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); @@ -69,24 +61,16 @@ describe('connector_edit_flyout', () => { }; actionTypeRegistry.get.mockReturnValue(actionType); actionTypeRegistry.has.mockReturnValue(true); - + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; const wrapper = mountWithIntl( - - { - return new Promise(() => {}); - }, - docLinks: deps.docLinks, - }} - > - {}} /> - - + {}} + reloadConnectors={() => { + return new Promise(() => {}); + }} + actionTypeRegistry={actionTypeRegistry} + /> ); const connectorNameField = wrapper.find('[data-test-subj="nameInput"]'); @@ -122,24 +106,17 @@ describe('connector_edit_flyout', () => { }; actionTypeRegistry.get.mockReturnValue(actionType); actionTypeRegistry.has.mockReturnValue(true); + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; const wrapper = mountWithIntl( - - { - return new Promise(() => {}); - }, - docLinks: deps.docLinks, - }} - > - {}} /> - - + {}} + reloadConnectors={() => { + return new Promise(() => {}); + }} + actionTypeRegistry={actionTypeRegistry} + /> ); const preconfiguredBadge = wrapper.find('[data-test-subj="preconfiguredBadge"]'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index d81f30e4f36471..3cf6e18e89621e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -26,21 +26,24 @@ import { i18n } from '@kbn/i18n'; import { Option, none, some } from 'fp-ts/lib/Option'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; import { TestConnectorForm } from './test_connector_form'; -import { ActionConnector, IErrorObject } from '../../../types'; +import { ActionConnector, ActionTypeRegistryContract, IErrorObject } from '../../../types'; import { connectorReducer } from './connector_reducer'; import { updateActionConnector, executeAction } from '../../lib/action_connector_api'; import { hasSaveActionsCapability } from '../../lib/capabilities'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { ActionTypeExecutorResult, isActionTypeExecutorResult, } from '../../../../../actions/common'; import './connector_edit_flyout.scss'; +import { useKibana } from '../../../common/lib/kibana'; -export interface ConnectorEditProps { +export interface ConnectorEditFlyoutProps { initialConnector: ActionConnector; onClose: () => void; tab?: EditConectorTabs; + reloadConnectors?: () => Promise; + consumer?: string; + actionTypeRegistry: ActionTypeRegistryContract; } export enum EditConectorTabs { @@ -52,16 +55,16 @@ export const ConnectorEditFlyout = ({ initialConnector, onClose, tab = EditConectorTabs.Configuration, -}: ConnectorEditProps) => { + reloadConnectors, + consumer, + actionTypeRegistry, +}: ConnectorEditFlyoutProps) => { const { http, - toastNotifications, - capabilities, - actionTypeRegistry, - reloadConnectors, + notifications: { toasts }, docLinks, - consumer, - } = useActionsConnectorsContext(); + application: { capabilities }, + } = useKibana().services; const canSave = hasSaveActionsCapability(capabilities); const [{ connector }, dispatch] = useReducer(connectorReducer, { @@ -105,7 +108,7 @@ export const ConnectorEditFlyout = ({ const onActionConnectorSave = async (): Promise => await updateActionConnector({ http, connector, id: connector.id }) .then((savedConnector) => { - toastNotifications.addSuccess( + toasts.addSuccess( i18n.translate( 'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText', { @@ -119,7 +122,7 @@ export const ConnectorEditFlyout = ({ return savedConnector; }) .catch((errorRes) => { - toastNotifications.addDanger( + toasts.addDanger( errorRes.body?.message ?? i18n.translate( 'xpack.triggersActionsUI.sections.editConnectorForm.updateErrorNotificationText', @@ -254,9 +257,6 @@ export const ConnectorEditFlyout = ({ dispatch(changes); }} actionTypeRegistry={actionTypeRegistry} - http={http} - docLinks={docLinks} - capabilities={capabilities} consumer={consumer} /> ) : ( @@ -289,6 +289,7 @@ export const ConnectorEditFlyout = ({ onExecutAction={onExecutAction} isExecutingAction={isExecutingAction} executionResult={testExecutionResult} + actionTypeRegistry={actionTypeRegistry} /> )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx index f4a1aca7f8f242..ee6f67ca1e3e3c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx @@ -5,14 +5,13 @@ */ import React, { lazy } from 'react'; import { I18nProvider } from '@kbn/i18n/react'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; import TestConnectorForm from './test_connector_form'; import { none, some } from 'fp-ts/lib/Option'; import { ActionConnector, ValidationResult } from '../../../types'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { EuiFormRow, EuiFieldText, EuiText, EuiLink, EuiForm, EuiSelect } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test/jest'; +jest.mock('../../../common/lib/kibana'); const mockedActionParamsFields = lazy(async () => ({ default() { @@ -58,29 +57,10 @@ const actionType = { actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, }; +const actionTypeRegistry = actionTypeRegistryMock.create(); +actionTypeRegistry.get.mockReturnValue(actionType); describe('test_connector_form', () => { - let deps: any; - let actionTypeRegistry; - beforeAll(async () => { - actionTypeRegistry = actionTypeRegistryMock.create(); - - const mocks = coreMock.createSetup(); - const [ - { - application: { capabilities }, - }, - ] = await mocks.getStartServices(); - deps = { - http: mocks.http, - toastNotifications: mocks.notifications.toasts, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - actionTypeRegistry, - capabilities, - }; - actionTypeRegistry.get.mockReturnValue(actionType); - }); - it('renders initially as the action form and execute button and no result', async () => { const connector = { actionTypeId: actionType.id, @@ -89,31 +69,19 @@ describe('test_connector_form', () => { } as ActionConnector; const wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - docLinks: deps!.docLinks, - }} - > - {}} - isExecutingAction={false} - onExecutAction={async () => ({ - actionId: '', - status: 'ok', - })} - executionResult={none} - /> - + {}} + isExecutingAction={false} + onExecutAction={async () => ({ + actionId: '', + status: 'ok', + })} + executionResult={none} + actionTypeRegistry={actionTypeRegistry} + /> ); const executeActionButton = wrapper?.find('[data-test-subj="executeActionButton"]'); @@ -132,34 +100,22 @@ describe('test_connector_form', () => { } as ActionConnector; const wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - docLinks: deps!.docLinks, - }} - > - {}} - isExecutingAction={false} - onExecutAction={async () => ({ - actionId: '', - status: 'ok', - })} - executionResult={some({ - actionId: '', - status: 'ok', - })} - /> - + {}} + isExecutingAction={false} + onExecutAction={async () => ({ + actionId: '', + status: 'ok', + })} + executionResult={some({ + actionId: '', + status: 'ok', + })} + actionTypeRegistry={actionTypeRegistry} + /> ); const result = wrapper?.find('[data-test-subj="executionSuccessfulResult"]'); @@ -174,36 +130,24 @@ describe('test_connector_form', () => { } as ActionConnector; const wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - docLinks: deps!.docLinks, - }} - > - {}} - isExecutingAction={false} - onExecutAction={async () => ({ - actionId: '', - status: 'error', - message: 'Error Message', - })} - executionResult={some({ - actionId: '', - status: 'error', - message: 'Error Message', - })} - /> - + {}} + isExecutingAction={false} + onExecutAction={async () => ({ + actionId: '', + status: 'error', + message: 'Error Message', + })} + executionResult={some({ + actionId: '', + status: 'error', + message: 'Error Message', + })} + actionTypeRegistry={actionTypeRegistry} + /> ); const result = wrapper?.find('[data-test-subj="executionFailureResult"]'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx index 4d9a327f97b054..c1a4351f384b10 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx @@ -20,8 +20,7 @@ import { Option, map, getOrElse } from 'fp-ts/lib/Option'; import { pipe } from 'fp-ts/lib/pipeable'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { ActionConnector } from '../../../types'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { ActionConnector, ActionTypeRegistryContract } from '../../../types'; import { ActionTypeExecutorResult } from '../../../../../actions/common'; export interface ConnectorAddFlyoutProps { @@ -32,6 +31,7 @@ export interface ConnectorAddFlyoutProps { actionParams: Record; onExecutAction: () => Promise>; executionResult: Option>; + actionTypeRegistry: ActionTypeRegistryContract; } export const TestConnectorForm = ({ @@ -42,8 +42,8 @@ export const TestConnectorForm = ({ setActionParams, onExecutAction, isExecutingAction, + actionTypeRegistry, }: ConnectorAddFlyoutProps) => { - const { actionTypeRegistry, docLinks, http, toastNotifications } = useActionsConnectorsContext(); const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); const ParamsFieldsComponent = actionTypeModel.actionParamsFields; @@ -75,9 +75,6 @@ export const TestConnectorForm = ({ }) } messageVariables={[]} - docLinks={docLinks} - http={http} - toastNotifications={toastNotifications} actionConnector={connector} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 226b9de8b677f2..27dc4f7fd9e984 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -7,15 +7,13 @@ import * as React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { ActionsConnectorsList } from './actions_connectors_list'; -import { coreMock, scopedHistoryMock } from '../../../../../../../../src/core/public/mocks'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; -import { AppContextProvider } from '../../../app_context'; -import { chartPluginMock } from '../../../../../../../../src/plugins/charts/public/mocks'; -import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; -import { alertingPluginMock } from '../../../../../../alerts/public/mocks'; -import { featuresPluginMock } from '../../../../../../features/public/mocks'; +import { useKibana } from '../../../../common/lib/kibana'; + +jest.mock('../../../../common/lib/kibana'); import { ActionConnector } from '../../../../types'; import { times } from 'lodash'; @@ -23,16 +21,15 @@ jest.mock('../../../lib/action_connector_api', () => ({ loadAllActions: jest.fn(), loadActionTypes: jest.fn(), })); - +const useKibanaMock = useKibana as jest.Mocked; const actionTypeRegistry = actionTypeRegistryMock.create(); +const mocks = coreMock.createSetup(); +const { loadAllActions, loadActionTypes } = jest.requireMock('../../../lib/action_connector_api'); describe('actions_connectors_list component empty', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAllActions, loadActionTypes } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAllActions.mockResolvedValueOnce([]); loadActionTypes.mockResolvedValueOnce([ { @@ -44,47 +41,26 @@ describe('actions_connectors_list component empty', () => { name: 'Test2', }, ]); - const mockes = coreMock.createSetup(); + actionTypeRegistry.has.mockReturnValue(true); + const [ { - chrome, - docLinks, - application: { capabilities, navigateToApp }, + application: { capabilities }, }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities: { - ...capabilities, - actions: { - show: true, - save: true, - delete: true, - }, + ] = await mocks.getStartServices(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, }, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry, - alertTypeRegistry: {} as any, - kibanaFeatures, }; - actionTypeRegistry.has.mockReturnValue(true); - - wrapper = mountWithIntl( - - - - ); + wrapper = mountWithIntl(); // Wait for active space to resolve before requesting the component to update await act(async () => { @@ -95,7 +71,9 @@ describe('actions_connectors_list component empty', () => { it('renders empty prompt', async () => { await setup(); - expect(wrapper.find('EuiEmptyPrompt')).toHaveLength(1); + expect( + wrapper.find('[data-test-subj="createFirstConnectorEmptyPrompt"]').find('EuiEmptyPrompt') + ).toHaveLength(1); expect( wrapper.find('[data-test-subj="createFirstActionButton"]').find('EuiButton') ).toHaveLength(1); @@ -112,9 +90,6 @@ describe('actions_connectors_list component with items', () => { let wrapper: ReactWrapper; async function setup(actionConnectors?: ActionConnector[]) { - const { loadAllActions, loadActionTypes } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAllActions.mockResolvedValueOnce( actionConnectors ?? [ { @@ -156,50 +131,24 @@ describe('actions_connectors_list component with items', () => { }, ]); - const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, - application: { capabilities, navigateToApp }, + application: { capabilities }, }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities: { - ...capabilities, - actions: { - show: true, - save: true, - delete: true, - }, + ] = await mocks.getStartServices(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, }, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry: { - get() { - return null; - }, - } as any, - alertTypeRegistry: {} as any, - kibanaFeatures, }; - - wrapper = mountWithIntl( - - - - ); + wrapper = mountWithIntl(); // Wait for active space to resolve before requesting the component to update await act(async () => { @@ -263,9 +212,6 @@ describe('actions_connectors_list component empty with show only capability', () let wrapper: ReactWrapper; async function setup() { - const { loadAllActions, loadActionTypes } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAllActions.mockResolvedValueOnce([]); loadActionTypes.mockResolvedValueOnce([ { @@ -277,50 +223,24 @@ describe('actions_connectors_list component empty with show only capability', () name: 'Test2', }, ]); - const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, - application: { capabilities, navigateToApp }, + application: { capabilities }, }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities: { - ...capabilities, - actions: { - show: true, - save: false, - delete: false, - }, + ] = await mocks.getStartServices(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: false, + delete: false, }, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry: { - get() { - return null; - }, - } as any, - alertTypeRegistry: {} as any, - kibanaFeatures, }; - - wrapper = mountWithIntl( - - - - ); + wrapper = mountWithIntl(); // Wait for active space to resolve before requesting the component to update await act(async () => { @@ -340,9 +260,6 @@ describe('actions_connectors_list with show only capability', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAllActions, loadActionTypes } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAllActions.mockResolvedValueOnce([ { id: '1', @@ -369,50 +286,24 @@ describe('actions_connectors_list with show only capability', () => { name: 'Test2', }, ]); - const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, - application: { capabilities, navigateToApp }, + application: { capabilities }, }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities: { - ...capabilities, - actions: { - show: true, - save: false, - delete: false, - }, + ] = await mocks.getStartServices(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: false, + delete: false, }, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry: { - get() { - return null; - }, - } as any, - alertTypeRegistry: {} as any, - kibanaFeatures, }; - - wrapper = mountWithIntl( - - - - ); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + wrapper = mountWithIntl(); // Wait for active space to resolve before requesting the component to update await act(async () => { @@ -437,9 +328,6 @@ describe('actions_connectors_list component with disabled items', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAllActions, loadActionTypes } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAllActions.mockResolvedValueOnce([ { id: '1', @@ -473,50 +361,24 @@ describe('actions_connectors_list component with disabled items', () => { }, ]); - const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, - application: { capabilities, navigateToApp }, + application: { capabilities }, }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities: { - ...capabilities, - actions: { - show: true, - save: true, - delete: true, - }, + ] = await mocks.getStartServices(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: true, + delete: true, }, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry: { - get() { - return null; - }, - } as any, - alertTypeRegistry: {} as any, - kibanaFeatures, }; - - wrapper = mountWithIntl( - - - - ); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + wrapper = mountWithIntl(); // Wait for active space to resolve before requesting the component to update await act(async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index c5d0a6aae54fc6..fed888b40ad86b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -23,7 +23,6 @@ import { import { i18n } from '@kbn/i18n'; import { omit } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; -import { useAppDependencies } from '../../../app_context'; import { loadAllActions, loadActionTypes, deleteActions } from '../../../lib/action_connector_api'; import ConnectorAddFlyout from '../../action_connector_form/connector_add_flyout'; import ConnectorEditFlyout, { @@ -35,20 +34,19 @@ import { hasExecuteActionsCapability, } from '../../../lib/capabilities'; import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; -import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context'; import { checkActionTypeEnabled } from '../../../lib/check_action_type_enabled'; import './actions_connectors_list.scss'; import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types'; import { EmptyConnectorsPrompt } from '../../../components/prompts/empty_connectors_prompt'; +import { useKibana } from '../../../../common/lib/kibana'; export const ActionsConnectorsList: React.FunctionComponent = () => { const { http, - toastNotifications, - capabilities, + notifications: { toasts }, + application: { capabilities }, actionTypeRegistry, - docLinks, - } = useAppDependencies(); + } = useKibana().services; const canDelete = hasDeleteActionsCapability(capabilities); const canExecute = hasExecuteActionsCapability(capabilities); const canSave = hasSaveActionsCapability(capabilities); @@ -82,7 +80,7 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { } setActionTypesIndex(index); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionTypesMessage', { defaultMessage: 'Unable to load action types' } @@ -121,7 +119,7 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { const actionsResponse = await loadAllActions({ http }); setActions(actionsResponse); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionsMessage', { @@ -366,37 +364,30 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { setAddFlyoutVisibility(true)} /> )} {actionConnectorTableItems.length === 0 && !canSave && } - - {addFlyoutVisible ? ( - { - setAddFlyoutVisibility(false); - }} - onTestConnector={(connector) => editItem(connector, EditConectorTabs.Test)} - /> - ) : null} - {editConnectorProps.initialConnector ? ( - { - setEditConnectorProps(omit(editConnectorProps, 'initialConnector')); - }} - /> - ) : null} - + {addFlyoutVisible ? ( + { + setAddFlyoutVisibility(false); + }} + onTestConnector={(connector) => editItem(connector, EditConectorTabs.Test)} + reloadConnectors={loadActions} + actionTypeRegistry={actionTypeRegistry} + /> + ) : null} + {editConnectorProps.initialConnector ? ( + { + setEditConnectorProps(omit(editConnectorProps, 'initialConnector')); + }} + reloadConnectors={loadActions} + actionTypeRegistry={actionTypeRegistry} + /> + ) : null} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index c2a7635b4cf96b..2f7a31721fa076 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -7,45 +7,17 @@ import * as React from 'react'; import uuid from 'uuid'; import { shallow } from 'enzyme'; import { AlertDetails } from './alert_details'; -import { Alert, ActionType, ValidationResult } from '../../../../types'; +import { Alert, ActionType, AlertTypeModel } from '../../../../types'; import { EuiTitle, EuiBadge, EuiFlexItem, EuiSwitch, EuiButtonEmpty, EuiText } from '@elastic/eui'; import { ViewInApp } from './view_in_app'; -import { coreMock } from 'src/core/public/mocks'; import { AlertExecutionStatusErrorReasons, ALERTS_FEATURE_ID, } from '../../../../../../alerts/common'; +import { useKibana } from '../../../../common/lib/kibana'; +import { alertTypeRegistryMock } from '../../../alert_type_registry.mock'; -const mockes = coreMock.createSetup(); - -jest.mock('../../../app_context', () => ({ - useAppDependencies: jest.fn(() => ({ - http: jest.fn(), - capabilities: { - get: jest.fn(() => ({})), - }, - actionTypeRegistry: jest.fn(), - alertTypeRegistry: { - has: jest.fn().mockReturnValue(true), - register: jest.fn(), - get: jest.fn().mockReturnValue({ - id: 'my-alert-type', - iconClass: 'test', - name: 'test-alert', - validate: (): ValidationResult => { - return { errors: {} }; - }, - requiresAppContext: false, - }), - list: jest.fn(), - }, - toastNotifications: mockes.notifications.toasts, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - uiSettings: mockes.uiSettings, - data: jest.fn(), - charts: jest.fn(), - })), -})); +jest.mock('../../../../common/lib/kibana'); jest.mock('react-router-dom', () => ({ useHistory: () => ({ @@ -61,6 +33,8 @@ jest.mock('../../../lib/capabilities', () => ({ hasSaveAlertsCapability: jest.fn(() => true), hasExecuteActionsCapability: jest.fn(() => true), })); +const useKibanaMock = useKibana as jest.Mocked; +const alertTypeRegistry = alertTypeRegistryMock.create(); const mockAlertApis = { muteAlert: jest.fn(), @@ -631,6 +605,21 @@ describe('edit button', () => { minimumLicenseRequired: 'basic', }, ]; + alertTypeRegistry.has.mockReturnValue(true); + const alertTypeR = ({ + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: () => {}, + requiresAppContext: false, + } as unknown) as AlertTypeModel; + alertTypeRegistry.get.mockReturnValue(alertTypeR); + useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; it('should render an edit button when alert and actions are editable', () => { const alert = mockAlert({ @@ -714,7 +703,7 @@ describe('edit button', () => { ).toBeFalsy(); }); - it('should render an edit button when alert editable but actions arent when there are no actions on the alert', () => { + it('should render an edit button when alert editable but actions arent when there are no actions on the alert', async () => { const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); hasExecuteActionsCapability.mockReturnValue(false); const alert = mockAlert({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index d7de7e0a82c1ec..03734779886b31 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -26,7 +26,6 @@ import { EuiButton, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { useAppDependencies } from '../../../app_context'; import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; import { getAlertingSectionBreadcrumb, getAlertDetailsBreadcrumb } from '../../../lib/breadcrumb'; import { getCurrentDocTitle } from '../../../lib/doc_title'; @@ -41,6 +40,7 @@ import { AlertEdit } from '../../alert_form'; import { AlertsContextProvider } from '../../../context/alerts_context'; import { routeToAlertDetails } from '../../../constants'; import { alertsErrorReasonTranslationsMapping } from '../../alerts_list/translations'; +import { useKibana } from '../../../../common/lib/kibana'; import { alertReducer } from '../../alert_form/alert_reducer'; type AlertDetailsProps = { @@ -63,8 +63,8 @@ export const AlertDetails: React.FunctionComponent = ({ const history = useHistory(); const { http, - toastNotifications, - capabilities, + notifications: { toasts }, + application: { capabilities }, alertTypeRegistry, actionTypeRegistry, uiSettings, @@ -73,7 +73,7 @@ export const AlertDetails: React.FunctionComponent = ({ data, setBreadcrumbs, chrome, - } = useAppDependencies(); + } = useKibana().services; const [{}, dispatch] = useReducer(alertReducer, { alert }); const setInitialAlert = (value: Alert) => { dispatch({ command: { type: 'setAlert' }, payload: { key: 'alert', value } }); @@ -158,7 +158,7 @@ export const AlertDetails: React.FunctionComponent = ({ http, actionTypeRegistry, alertTypeRegistry, - toastNotifications, + toastNotifications: toasts, uiSettings, docLinks, charts, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx index 5ed924c37fe7a5..43ece9fc10c319 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx @@ -11,13 +11,8 @@ import { ToastsApi } from 'kibana/public'; import { AlertDetailsRoute, getAlertData } from './alert_details_route'; import { Alert } from '../../../../types'; import { EuiLoadingSpinner } from '@elastic/eui'; +jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../app_context', () => { - const toastNotifications = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ toastNotifications })), - }; -}); describe('alert_details_route', () => { it('render a loader while fetching data', () => { const alert = mockAlert(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx index b10152b4364c5f..fc3e05fbfaed0e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx @@ -10,7 +10,6 @@ import { RouteComponentProps } from 'react-router-dom'; import { EuiLoadingSpinner } from '@elastic/eui'; import { ToastsApi } from 'kibana/public'; import { Alert, AlertType, ActionType } from '../../../../types'; -import { useAppDependencies } from '../../../app_context'; import { AlertDetailsWithApi as AlertDetails } from './alert_details'; import { throwIfAbsent, throwIfIsntContained } from '../../../lib/value_validators'; import { @@ -21,6 +20,7 @@ import { ComponentOpts as ActionApis, withActionOperations, } from '../../common/components/with_actions_api_operations'; +import { useKibana } from '../../../../common/lib/kibana'; type AlertDetailsRouteProps = RouteComponentProps<{ alertId: string; @@ -36,7 +36,10 @@ export const AlertDetailsRoute: React.FunctionComponent loadAlertTypes, loadActionTypes, }) => { - const { http, toastNotifications } = useAppDependencies(); + const { + http, + notifications: { toasts }, + } = useKibana().services; const [alert, setAlert] = useState(null); const [alertType, setAlertType] = useState(null); @@ -51,9 +54,9 @@ export const AlertDetailsRoute: React.FunctionComponent setAlert, setAlertType, setActionTypes, - toastNotifications + toasts ); - }, [alertId, http, loadActionTypes, loadAlert, loadAlertTypes, toastNotifications, refreshToken]); + }, [alertId, http, loadActionTypes, loadAlert, loadAlertTypes, toasts, refreshToken]); return alert && alertType && actionTypes ? ( >, setAlertType: React.Dispatch>, setActionTypes: React.Dispatch>, - toastNotifications: Pick + toasts: Pick ) { try { const loadedAlert = await loadAlert(alertId); @@ -104,7 +107,7 @@ export async function getAlertData( setAlertType(loadedAlertType); setActionTypes(loadedActionTypes); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertMessage', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx index 25bbe977fd76ad..52a85e8bc57bda 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx @@ -9,6 +9,7 @@ import { shallow } from 'enzyme'; import { AlertInstances, AlertInstanceListItem, alertInstanceToListItem } from './alert_instances'; import { Alert, AlertInstanceSummary, AlertInstanceStatus, AlertType } from '../../../../types'; import { EuiBasicTable } from '@elastic/eui'; +jest.mock('../../../../common/lib/kibana'); const fakeNow = new Date('2020-02-09T23:15:41.941Z'); const fake2MinutesAgo = new Date('2020-02-09T23:13:41.941Z'); @@ -24,13 +25,6 @@ beforeAll(() => { global.Date.now = jest.fn(() => fakeNow.getTime()); }); -jest.mock('../../../app_context', () => { - const toastNotifications = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ toastNotifications })), - }; -}); - describe('alert_instances', () => { it('render a list of alert instances', () => { const alert = mockAlert(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx index 3a171d469d4ad6..2256efe30831bd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx @@ -10,16 +10,11 @@ import { ToastsApi } from 'kibana/public'; import { AlertInstancesRoute, getAlertInstanceSummary } from './alert_instances_route'; import { Alert, AlertInstanceSummary, AlertType } from '../../../../types'; import { EuiLoadingSpinner } from '@elastic/eui'; +jest.mock('../../../../common/lib/kibana'); const fakeNow = new Date('2020-02-09T23:15:41.941Z'); const fake2MinutesAgo = new Date('2020-02-09T23:13:41.941Z'); -jest.mock('../../../app_context', () => { - const toastNotifications = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ toastNotifications })), - }; -}); describe('alert_instance_summary_route', () => { it('render a loader while fetching data', () => { const alert = mockAlert(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx index 83a09e9eafcc15..e1e0866d886a30 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx @@ -9,12 +9,12 @@ import { ToastsApi } from 'kibana/public'; import React, { useState, useEffect } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; import { Alert, AlertInstanceSummary, AlertType } from '../../../../types'; -import { useAppDependencies } from '../../../app_context'; import { ComponentOpts as AlertApis, withBulkAlertOperations, } from '../../common/components/with_bulk_alert_api_operations'; import { AlertInstancesWithApi as AlertInstances } from './alert_instances'; +import { useKibana } from '../../../../common/lib/kibana'; type WithAlertInstanceSummaryProps = { alert: Alert; @@ -30,19 +30,16 @@ export const AlertInstancesRoute: React.FunctionComponent { - const { toastNotifications } = useAppDependencies(); + const { + notifications: { toasts }, + } = useKibana().services; const [alertInstanceSummary, setAlertInstanceSummary] = useState( null ); useEffect(() => { - getAlertInstanceSummary( - alert.id, - loadAlertInstanceSummary, - setAlertInstanceSummary, - toastNotifications - ); + getAlertInstanceSummary(alert.id, loadAlertInstanceSummary, setAlertInstanceSummary, toasts); // eslint-disable-next-line react-hooks/exhaustive-deps }, [alert]); @@ -70,13 +67,13 @@ export async function getAlertInstanceSummary( alertId: string, loadAlertInstanceSummary: AlertApis['loadAlertInstanceSummary'], setAlertInstanceSummary: React.Dispatch>, - toastNotifications: Pick + toasts: Pick ) { try { const loadedInstanceSummary = await loadAlertInstanceSummary(alertId); setAlertInstanceSummary(loadedInstanceSummary); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertInstanceSummaryMessage', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx index 7e43fd22ff8c8e..d026c43b8496a0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx @@ -10,28 +10,8 @@ import { act } from 'react-dom/test-utils'; import { Alert } from '../../../../types'; import { ViewInApp } from './view_in_app'; -import { useAppDependencies } from '../../../app_context'; - -jest.mock('../../../app_context', () => { - const alerts = { - getNavigation: jest.fn(async (id) => - id === 'alert-with-nav' ? { path: '/alert' } : undefined - ), - }; - const navigateToApp = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ - http: jest.fn(), - navigateToApp, - alerts, - legacy: { - capabilities: { - get: jest.fn(() => ({})), - }, - }, - })), - }; -}); +import { useKibana } from '../../../../common/lib/kibana'; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../lib/capabilities', () => ({ hasSaveAlertsCapability: jest.fn(() => true), @@ -41,8 +21,7 @@ describe('view in app', () => { describe('link to the app that created the alert', () => { it('is disabled when there is no navigation', async () => { const alert = mockAlert(); - const { alerts } = useAppDependencies(); - + const { alerts } = useKibana().services; let component: ReactWrapper; await act(async () => { // use mount as we need useEffect to run @@ -59,7 +38,9 @@ describe('view in app', () => { it('enabled when there is navigation', async () => { const alert = mockAlert({ id: 'alert-with-nav', consumer: 'siem' }); - const { navigateToApp } = useAppDependencies(); + const { + application: { navigateToApp }, + } = useKibana().services; let component: ReactWrapper; act(async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.tsx index 5b5de070a94e63..865958ea285653 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.tsx @@ -10,7 +10,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { CoreStart } from 'kibana/public'; import { fromNullable, fold } from 'fp-ts/lib/Option'; import { pipe } from 'fp-ts/lib/pipeable'; -import { useAppDependencies } from '../../../app_context'; import { AlertNavigation, @@ -18,6 +17,7 @@ import { AlertUrlNavigation, } from '../../../../../../alerts/common'; import { Alert } from '../../../../types'; +import { useKibana } from '../../../../common/lib/kibana'; export interface ViewInAppProps { alert: Alert; @@ -28,7 +28,10 @@ const NO_NAVIGATION = false; type AlertNavigationLoadingState = AlertNavigation | false | null; export const ViewInApp: React.FunctionComponent = ({ alert }) => { - const { navigateToApp, alerts: maybeAlerting } = useAppDependencies(); + const { + application: { navigateToApp }, + alerts: maybeAlerting, + } = useKibana().services; const [alertNavigation, setAlertNavigation] = useState(null); useEffect(() => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index a69ee7102c13cd..084da8905663e0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -17,8 +17,9 @@ import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { ReactWrapper } from 'enzyme'; -import { AppContextProvider } from '../../app_context'; import { ALERTS_FEATURE_ID } from '../../../../../alerts/common'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; + jest.mock('../../lib/alert_api', () => ({ loadAlertTypes: jest.fn(), health: jest.fn((async) => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: true })), @@ -130,7 +131,7 @@ describe('alert_add', () => { actionTypeRegistry.has.mockReturnValue(true); wrapper = mountWithIntl( - + { @@ -160,7 +161,7 @@ describe('alert_add', () => { initialValues={initialValues} /> - + ); // Wait for active space to resolve before requesting the component to update diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index 77941d5c2bca15..11a35b313ef194 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -13,7 +13,7 @@ import { AlertsContextProvider } from '../../context/alerts_context'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { ReactWrapper } from 'enzyme'; import AlertEdit from './alert_edit'; -import { AppContextProvider } from '../../app_context'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -122,7 +122,7 @@ describe('alert_edit', () => { actionTypeRegistry.has.mockReturnValue(true); wrapper = mountWithIntl( - + { @@ -139,7 +139,7 @@ describe('alert_edit', () => { > {}} initialAlert={alert} /> - + ); // Wait for active space to resolve before requesting the component to update await act(async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 7fd5bdc8d8707d..a950af9c99a515 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -496,12 +496,8 @@ export const AlertForm = ({ } setAlertProperty={setActions} setActionParamsProperty={setActionParamsProperty} - http={http} actionTypeRegistry={actionTypeRegistry} defaultActionMessage={alertTypeModel?.defaultActionMessage} - toastNotifications={toastNotifications} - docLinks={docLinks} - capabilities={capabilities} /> ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index a29c112b536fb8..351eccf2934be3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -6,22 +6,18 @@ import * as React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test/jest'; -import { coreMock, scopedHistoryMock } from '../../../../../../../../src/core/public/mocks'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; import { alertTypeRegistryMock } from '../../../alert_type_registry.mock'; import { AlertsList } from './alerts_list'; import { ValidationResult } from '../../../../types'; -import { AppContextProvider } from '../../../app_context'; -import { chartPluginMock } from '../../../../../../../../src/plugins/charts/public/mocks'; -import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; -import { alertingPluginMock } from '../../../../../../alerts/public/mocks'; import { AlertExecutionStatusErrorReasons, ALERTS_FEATURE_ID, } from '../../../../../../alerts/common'; -import { featuresPluginMock } from '../../../../../../features/public/mocks'; +import { useKibana } from '../../../../common/lib/kibana'; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../lib/action_connector_api', () => ({ loadActionTypes: jest.fn(), @@ -39,6 +35,8 @@ jest.mock('react-router-dom', () => ({ pathname: '/triggersActions/alerts/', }), })); +const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); +const { loadActionTypes, loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -67,14 +65,11 @@ const alertTypeFromApi = { }; alertTypeRegistry.list.mockReturnValue([alertType]); actionTypeRegistry.list.mockReturnValue([]); +const useKibanaMock = useKibana as jest.Mocked; describe('alerts_list component empty', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); - const { loadActionTypes, loadAllActions } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, @@ -94,40 +89,13 @@ describe('alerts_list component empty', () => { loadAlertTypes.mockResolvedValue([alertTypeFromApi]); loadAllActions.mockResolvedValue([]); - const mockes = coreMock.createSetup(); - const [ - { - chrome, - docLinks, - application: { capabilities, navigateToApp }, - }, - ] = await mockes.getStartServices(); - - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry, - alertTypeRegistry, - kibanaFeatures, - }; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; - wrapper = mountWithIntl( - - - - ); + wrapper = mountWithIntl(); await act(async () => { await nextTick(); @@ -153,10 +121,6 @@ describe('alerts_list component with items', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); - const { loadActionTypes, loadAllActions } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, @@ -267,40 +231,14 @@ describe('alerts_list component with items', () => { ]); loadAlertTypes.mockResolvedValue([alertTypeFromApi]); loadAllActions.mockResolvedValue([]); - const mockes = coreMock.createSetup(); - const [ - { - chrome, - docLinks, - application: { capabilities, navigateToApp }, - }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry, - alertTypeRegistry, - kibanaFeatures, - }; alertTypeRegistry.has.mockReturnValue(true); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; - wrapper = mountWithIntl( - - - - ); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + wrapper = mountWithIntl(); await act(async () => { await nextTick(); @@ -328,10 +266,6 @@ describe('alerts_list component empty with show only capability', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); - const { loadActionTypes, loadAllActions } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, @@ -350,42 +284,12 @@ describe('alerts_list component empty with show only capability', () => { ]); loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); loadAllActions.mockResolvedValue([]); - const mockes = coreMock.createSetup(); - const [ - { - chrome, - docLinks, - application: { capabilities, navigateToApp }, - }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry: { - get() { - return null; - }, - } as any, - alertTypeRegistry: {} as any, - kibanaFeatures, - }; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; - wrapper = mountWithIntl( - - - - ); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + wrapper = mountWithIntl(); await act(async () => { await nextTick(); @@ -403,10 +307,6 @@ describe('alerts_list with show only capability', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); - const { loadActionTypes, loadAllActions } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, @@ -471,40 +371,14 @@ describe('alerts_list with show only capability', () => { loadAlertTypes.mockResolvedValue([alertTypeFromApi]); loadAllActions.mockResolvedValue([]); - const mockes = coreMock.createSetup(); - const [ - { - chrome, - docLinks, - application: { capabilities, navigateToApp }, - }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry, - alertTypeRegistry, - kibanaFeatures, - }; alertTypeRegistry.has.mockReturnValue(false); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; - wrapper = mountWithIntl( - - - - ); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + wrapper = mountWithIntl(); await act(async () => { await nextTick(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 11d6f3470fec26..0a674b4d5486dc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -31,7 +31,6 @@ import { useHistory } from 'react-router-dom'; import { isEmpty } from 'lodash'; import { AlertsContextProvider } from '../../../context/alerts_context'; -import { useAppDependencies } from '../../../app_context'; import { ActionType, Alert, AlertTableItem, AlertTypeIndex, Pagination } from '../../../../types'; import { AlertAdd } from '../../alert_form'; import { BulkOperationPopover } from '../../common/components/bulk_operation_popover'; @@ -58,6 +57,7 @@ import { } from '../../../../../../alerts/common'; import { hasAllPrivilege } from '../../../lib/capabilities'; import { alertsStatusesTranslationsMapping } from '../translations'; +import { useKibana } from '../../../../common/lib/kibana'; const ENTER_KEY = 13; @@ -76,8 +76,8 @@ export const AlertsList: React.FunctionComponent = () => { const history = useHistory(); const { http, - toastNotifications, - capabilities, + notifications: { toasts }, + application: { capabilities }, alertTypeRegistry, actionTypeRegistry, uiSettings, @@ -85,7 +85,7 @@ export const AlertsList: React.FunctionComponent = () => { charts, data, kibanaFeatures, - } = useAppDependencies(); + } = useKibana().services; const canExecuteActions = hasExecuteActionsCapability(capabilities); const [actionTypes, setActionTypes] = useState([]); @@ -143,7 +143,7 @@ export const AlertsList: React.FunctionComponent = () => { } setAlertTypesState({ isLoading: false, data: index, isInitialized: true }); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertTypesMessage', { defaultMessage: 'Unable to load alert types' } @@ -160,7 +160,7 @@ export const AlertsList: React.FunctionComponent = () => { const result = await loadActionTypes({ http }); setActionTypes(result.filter((actionType) => actionTypeRegistry.has(actionType.id))); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.unableToLoadActionTypesMessage', { defaultMessage: 'Unable to load action types' } @@ -194,7 +194,7 @@ export const AlertsList: React.FunctionComponent = () => { setPage({ ...page, index: 0 }); } } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertsMessage', { @@ -220,7 +220,7 @@ export const AlertsList: React.FunctionComponent = () => { setAlertsStatusesTotal(alertsAggs.alertExecutionStatus); } } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertsStatusesInfoMessage', { @@ -664,7 +664,7 @@ export const AlertsList: React.FunctionComponent = () => { http, actionTypeRegistry, alertTypeRegistry, - toastNotifications, + toastNotifications: toasts, uiSettings, docLinks, charts, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx index 26bc8b869a06bd..f87768c8d45379 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx @@ -10,12 +10,12 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { Alert } from '../../../../types'; -import { useAppDependencies } from '../../../app_context'; import { withBulkAlertOperations, ComponentOpts as BulkOperationsComponentOpts, } from './with_bulk_alert_api_operations'; import './alert_quick_edit_buttons.scss'; +import { useKibana } from '../../../../common/lib/kibana'; export type ComponentOpts = { selectedItems: Alert[]; @@ -34,7 +34,9 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ disableAlerts, setAlertsToDelete, }: ComponentOpts) => { - const { toastNotifications } = useAppDependencies(); + const { + notifications: { toasts }, + } = useKibana().services; const [isMutingAlerts, setIsMutingAlerts] = useState(false); const [isUnmutingAlerts, setIsUnmutingAlerts] = useState(false); @@ -53,7 +55,7 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ try { await muteAlerts(selectedItems); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToMuteAlertsMessage', { @@ -73,7 +75,7 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ try { await unmuteAlerts(selectedItems); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToUnmuteAlertsMessage', { @@ -93,7 +95,7 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ try { await enableAlerts(selectedItems); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToEnableAlertsMessage', { @@ -113,7 +115,7 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ try { await disableAlerts(selectedItems); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToDisableAlertsMessage', { @@ -133,7 +135,7 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ try { setAlertsToDelete(selectedItems.map((selected: any) => selected.id)); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToDeleteAlertsMessage', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx index dd6b8775ba3d09..09a7fded9769c1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx @@ -7,19 +7,12 @@ import * as React from 'react'; import { shallow, mount } from 'enzyme'; import { withActionOperations, ComponentOpts } from './with_actions_api_operations'; import * as actionApis from '../../../lib/action_connector_api'; -import { useAppDependencies } from '../../../app_context'; +import { useKibana } from '../../../../common/lib/kibana'; +const useKibanaMock = useKibana as jest.Mocked; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../lib/action_connector_api'); -jest.mock('../../../app_context', () => { - const http = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ - http, - })), - }; -}); - describe('with_action_api_operations', () => { beforeEach(() => { jest.clearAllMocks(); @@ -36,7 +29,7 @@ describe('with_action_api_operations', () => { }); it('loadActionTypes calls the loadActionTypes api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ loadActionTypes }: ComponentOpts) => { return ; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx index 45e6c6b10532ce..ff66d31044b08e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx @@ -7,8 +7,8 @@ import React from 'react'; import { ActionType } from '../../../../types'; -import { useAppDependencies } from '../../../app_context'; import { loadActionTypes } from '../../../lib/action_connector_api'; +import { useKibana } from '../../../../common/lib/kibana'; export interface ComponentOpts { loadActionTypes: () => Promise; @@ -20,7 +20,10 @@ export function withActionOperations( WrappedComponent: React.ComponentType ): React.FunctionComponent> { return (props: PropsWithOptionalApiHandlers) => { - const { http } = useAppDependencies(); + const { http } = useKibana().services; + if (!http) { + throw new Error('KibanaContext has not been initalized correctly.'); + } return ( loadActionTypes({ http })} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx index 72d4f8857a610a..47ef744f5d95c3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx @@ -8,24 +8,12 @@ import { shallow, mount } from 'enzyme'; import uuid from 'uuid'; import { withBulkAlertOperations, ComponentOpts } from './with_bulk_alert_api_operations'; import * as alertApi from '../../../lib/alert_api'; -import { useAppDependencies } from '../../../app_context'; import { Alert } from '../../../../types'; +import { useKibana } from '../../../../common/lib/kibana'; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../lib/alert_api'); - -jest.mock('../../../app_context', () => { - const http = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ - http, - legacy: { - capabilities: { - get: jest.fn(() => ({})), - }, - }, - })), - }; -}); +const useKibanaMock = useKibana as jest.Mocked; describe('with_bulk_alert_api_operations', () => { beforeEach(() => { @@ -55,7 +43,7 @@ describe('with_bulk_alert_api_operations', () => { // single alert it('muteAlert calls the muteAlert api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ muteAlert, alert }: ComponentOpts & { alert: Alert }) => { return ; }; @@ -70,7 +58,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('unmuteAlert calls the unmuteAlert api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ unmuteAlert, alert }: ComponentOpts & { alert: Alert }) => { return ; }; @@ -85,7 +73,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('enableAlert calls the muteAlerts api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ enableAlert, alert }: ComponentOpts & { alert: Alert }) => { return ; }; @@ -100,7 +88,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('disableAlert calls the disableAlert api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ disableAlert, alert }: ComponentOpts & { alert: Alert }) => { return ; }; @@ -115,7 +103,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('deleteAlert calls the deleteAlert api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ deleteAlert, alert }: ComponentOpts & { alert: Alert }) => { return ; }; @@ -131,7 +119,7 @@ describe('with_bulk_alert_api_operations', () => { // bulk alerts it('muteAlerts calls the muteAlerts api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ muteAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { return ; }; @@ -146,7 +134,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('unmuteAlerts calls the unmuteAlerts api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ unmuteAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { return ; }; @@ -161,7 +149,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('enableAlerts calls the muteAlertss api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ enableAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { return ; }; @@ -180,7 +168,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('disableAlerts calls the disableAlerts api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ disableAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { return ; }; @@ -198,7 +186,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('deleteAlerts calls the deleteAlerts api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ deleteAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { return ; }; @@ -213,7 +201,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('loadAlert calls the loadAlert api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ loadAlert, alertId, @@ -231,7 +219,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('loadAlertTypes calls the loadAlertTypes api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ loadAlertTypes }: ComponentOpts) => { return ; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index dc961482f182d3..77f7631b6d63f9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -13,7 +13,6 @@ import { AlertInstanceSummary, AlertingFrameworkHealth, } from '../../../../types'; -import { useAppDependencies } from '../../../app_context'; import { deleteAlerts, disableAlerts, @@ -32,6 +31,7 @@ import { loadAlertTypes, health, } from '../../../lib/alert_api'; +import { useKibana } from '../../../../common/lib/kibana'; export interface ComponentOpts { muteAlerts: (alerts: Alert[]) => Promise; @@ -69,7 +69,10 @@ export function withBulkAlertOperations( WrappedComponent: React.ComponentType ): React.FunctionComponent> { return (props: PropsWithOptionalApiHandlers) => { - const { http } = useAppDependencies(); + const { http } = useKibana().services; + if (!http) { + throw new Error('KibanaContext has not been initalized correctly.'); + } return ( { + const ConnectorAddFlyoutLazy = lazy( + () => import('../application/sections/action_connector_form/connector_add_flyout') + ); + return ( + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_edit_connector_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_edit_connector_flyout.tsx new file mode 100644 index 00000000000000..a95e417da7f55b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_edit_connector_flyout.tsx @@ -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 React, { lazy, Suspense } from 'react'; +import type { ConnectorEditFlyoutProps } from '../application/sections/action_connector_form/connector_edit_flyout'; + +export const getEditConnectorFlyoutLazy = (props: ConnectorEditFlyoutProps) => { + const ConnectorEditFlyoutLazy = lazy( + () => import('../application/sections/action_connector_form/connector_edit_flyout') + ); + return ( + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/__mocks__/index.ts new file mode 100644 index 00000000000000..0ec5f0e5930119 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/__mocks__/index.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 { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; +import { notificationServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { + createKibanaContextProviderMock, + createStartServicesMock, + createWithKibanaMock, +} from '../kibana_react.mock'; +const mockStartServicesMock = createStartServicesMock(); +export const KibanaServices = { get: jest.fn(), getKibanaVersion: jest.fn(() => '8.0.0') }; +export const useKibana = jest.fn().mockReturnValue({ + services: { + ...mockStartServicesMock, + data: dataPluginMock.createStartContract(), + }, +}); +export const useHttp = jest.fn().mockReturnValue(createStartServicesMock().http); +export const useTimeZone = jest.fn(); +export const useDateFormat = jest.fn(); +export const useBasePath = jest.fn(() => '/test/base/path'); +export const useToasts = jest + .fn() + .mockReturnValue(notificationServiceMock.createStartContract().toasts); +export const useCurrentUser = jest.fn(); +export const withKibana = jest.fn(createWithKibanaMock()); +export const KibanaContextProvider = jest.fn(createKibanaContextProviderMock()); +export const useGetUserSavedObjectPermissions = jest.fn(); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/index.ts new file mode 100644 index 00000000000000..b9cb71d4adb475 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/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 * from './kibana_react'; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts new file mode 100644 index 00000000000000..ce1d9887bbb26f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; + +import { coreMock, scopedHistoryMock } from '../../../../../../../src/core/public/mocks'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { TriggersAndActionsUiServices } from '../../../application/app'; +import { AlertTypeRegistryContract, ActionTypeRegistryContract } from '../../../types'; + +export const createStartServicesMock = (): TriggersAndActionsUiServices => { + const core = coreMock.createStart(); + return { + ...core, + alertTypeRegistry: { + has: jest.fn(), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + } as AlertTypeRegistryContract, + notifications: core.notifications, + dataPlugin: jest.fn(), + navigateToApp: jest.fn(), + alerts: { + getNavigation: jest.fn(async (id) => + id === 'alert-with-nav' ? { path: '/alert' } : undefined + ), + }, + history: scopedHistoryMock.create(), + setBreadcrumbs: jest.fn(), + data: dataPluginMock.createStartContract(), + actionTypeRegistry: { + has: jest.fn(), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + } as ActionTypeRegistryContract, + charts: chartPluginMock.createStartContract(), + kibanaFeatures: [], + element: ({ + style: { cursor: 'pointer' }, + } as unknown) as HTMLElement, + } as TriggersAndActionsUiServices; +}; + +export const createWithKibanaMock = () => { + const services = createStartServicesMock(); + + return (Component: unknown) => (props: unknown) => { + return React.createElement(Component as string, { ...(props as object), kibana: { services } }); + }; +}; + +export const createKibanaContextProviderMock = () => { + const services = createStartServicesMock(); + + return ({ children }: { children: React.ReactNode }) => + React.createElement(KibanaContextProvider, { services }, children); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.ts new file mode 100644 index 00000000000000..483432251ceec4 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.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 { + KibanaContextProvider, + KibanaReactContextValue, + useKibana, + useUiSetting, + useUiSetting$, + withKibana, +} from '../../../../../../../src/plugins/kibana_react/public'; +import { TriggersAndActionsUiServices } from '../../../application/app'; + +export type KibanaContext = KibanaReactContextValue; +export interface WithKibanaProps { + kibana: KibanaContext; +} + +const useTypedKibana = () => useKibana(); + +export { + KibanaContextProvider, + useTypedKibana as useKibana, + useUiSetting, + useUiSetting$, + withKibana, +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 025741aa7f9bd5..6955a94326145d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -7,7 +7,6 @@ import { Plugin } from './plugin'; export { AlertsContextProvider, AlertsContextValue } from './application/context/alerts_context'; -export { ActionsConnectorsContextProvider } from './application/context/actions_connectors_context'; export { AlertAdd } from './application/sections/alert_form'; export { AlertEdit, @@ -47,3 +46,4 @@ export * from './plugin'; export { TIME_UNITS } from './application/constants'; export { getTimeUnitLabel } from './common/lib/get_time_unit_label'; export { ForLastExpression } from './common/expression_items/for_the_last'; +export { TriggersAndActionsUiServices } from '../public/application/app'; diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index a30747afe6914f..365eac9c031b43 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -7,6 +7,7 @@ import { CoreSetup, CoreStart, Plugin as CorePlugin } from 'src/core/public'; import { i18n } from '@kbn/i18n'; +import { ReactElement } from 'react'; import { FeaturesPluginStart } from '../../features/public'; import { registerBuiltInActionTypes } from './application/components/builtin_action_types'; import { ActionTypeModel, AlertTypeModel } from './types'; @@ -23,6 +24,10 @@ import { ChartsPluginStart } from '../../../../src/plugins/charts/public'; import { PluginStartContract as AlertingStart } from '../../alerts/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; +import type { ConnectorAddFlyoutProps } from './application/sections/action_connector_form/connector_add_flyout'; +import type { ConnectorEditFlyoutProps } from './application/sections/action_connector_form/connector_edit_flyout'; +import { getAddConnectorFlyoutLazy } from './common/get_add_connector_flyout'; +import { getEditConnectorFlyoutLazy } from './common/get_edit_connector_flyout'; export interface TriggersAndActionsUIPublicPluginSetup { actionTypeRegistry: TypeRegistry; @@ -32,6 +37,12 @@ export interface TriggersAndActionsUIPublicPluginSetup { export interface TriggersAndActionsUIPublicPluginStart { actionTypeRegistry: TypeRegistry; alertTypeRegistry: TypeRegistry; + getAddConnectorFlyout: ( + props: Omit + ) => ReactElement | null; + getEditConnectorFlyout: ( + props: Omit + ) => ReactElement | null; } interface PluginsSetup { @@ -100,23 +111,15 @@ export class Plugin unknown ]; - const { boot } = await import('./application/boot'); + const { renderApp } = await import('./application/app'); const kibanaFeatures = await pluginsStart.features.getFeatures(); - return boot({ + return renderApp({ + ...coreStart, data: pluginsStart.data, charts: pluginsStart.charts, alerts: pluginsStart.alerts, element: params.element, - toastNotifications: coreStart.notifications.toasts, storage: new Storage(window.localStorage), - http: coreStart.http, - uiSettings: coreStart.uiSettings, - docLinks: coreStart.docLinks, - chrome: coreStart.chrome, - savedObjects: coreStart.savedObjects, - I18nContext: coreStart.i18n.Context, - capabilities: coreStart.application.capabilities, - navigateToApp: coreStart.application.navigateToApp, setBreadcrumbs: params.setBreadcrumbs, history: params.history, actionTypeRegistry, @@ -140,6 +143,15 @@ export class Plugin return { actionTypeRegistry: this.actionTypeRegistry, alertTypeRegistry: this.alertTypeRegistry, + getAddConnectorFlyout: (props: Omit) => { + return getAddConnectorFlyoutLazy({ ...props, actionTypeRegistry: this.actionTypeRegistry }); + }, + getEditConnectorFlyout: (props: Omit) => { + return getEditConnectorFlyoutLazy({ + ...props, + actionTypeRegistry: this.actionTypeRegistry, + }); + }, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index cc0522eeb52a18..be8b7b9757e9e7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import type { HttpSetup, DocLinksStart, ToastsSetup } from 'kibana/public'; +import type { DocLinksStart } from 'kibana/public'; import { ComponentType } from 'react'; import { ActionGroup, AlertActionParam } from '../../alerts/common'; import { ActionType } from '../../actions/common'; @@ -43,8 +43,6 @@ export interface ActionConnectorFieldsProps { editActionConfig: (property: string, value: any) => void; editActionSecrets: (property: string, value: any) => void; errors: IErrorObject; - docLinks: DocLinksStart; - http?: HttpSetup; readOnly: boolean; consumer?: string; } @@ -56,9 +54,6 @@ export interface ActionParamsProps { errors: IErrorObject; messageVariables?: ActionVariable[]; defaultMessage?: string; - docLinks: DocLinksStart; - http: HttpSetup; - toastNotifications: ToastsSetup; actionConnector?: ActionConnector; } diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index a5b8bc859ad94f..c928ac0dc458fe 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -99,7 +99,9 @@ const Application = (props: UptimeAppProps) => { - + diff --git a/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx b/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx index 9baacaf21acd0d..33a186bfe626e5 100644 --- a/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx +++ b/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx @@ -4,40 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { useDispatch } from 'react-redux'; import { EuiButtonEmpty } from '@elastic/eui'; -import { HttpStart, DocLinksStart, NotificationsStart, ApplicationStart } from 'src/core/public'; -import { - ActionsConnectorsContextProvider, - ConnectorAddFlyout, - TriggersAndActionsUIPublicPluginStart, -} from '../../../../triggers_actions_ui/public'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public'; import { getConnectorsAction } from '../../state/alerts/alerts'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; interface Props { focusInput: () => void; } + interface KibanaDeps { triggersActionsUi: TriggersAndActionsUIPublicPluginStart; - application: ApplicationStart; - docLinks: DocLinksStart; - http: HttpStart; - notifications: NotificationsStart; } export const AddConnectorFlyout = ({ focusInput }: Props) => { const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); - const { services: { - triggersActionsUi: { actionTypeRegistry }, - application, - docLinks, - http, - notifications, + triggersActionsUi: { getAddConnectorFlyout }, }, } = useKibana(); @@ -48,6 +35,16 @@ export const AddConnectorFlyout = ({ focusInput }: Props) => { focusInput(); }, [addFlyoutVisible, dispatch, focusInput]); + const ConnectorAddFlyout = useMemo( + () => + getAddConnectorFlyout({ + consumer: 'uptime', + onClose: () => setAddFlyoutVisibility(false), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + return ( <> { defaultMessage="Create connector" /> - - {addFlyoutVisible ? ( - setAddFlyoutVisibility(false)} /> - ) : null} - + {addFlyoutVisible ? ConnectorAddFlyout : null} ); }; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts index e918ce174a031f..ab3a3f1c868ec1 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts @@ -26,8 +26,6 @@ const ALERT_INTERVALS_TO_WRITE = 5; const ALERT_INTERVAL_SECONDS = 3; const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000; -const DefaultActionMessage = `alert {{alertName}} group {{context.group}} value {{context.value}} exceeded threshold {{context.function}} over {{params.timeWindowSize}}{{params.timeWindowUnit}} on {{context.date}}`; - // eslint-disable-next-line import/no-default-export export default function alertTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -93,9 +91,9 @@ export default function alertTests({ getService }: FtrProviderContext) { expect(group).to.be('all documents'); // we'll check title and message in this test, but not subsequent ones - expect(title).to.be('alert always fire group all documents exceeded threshold'); + expect(title).to.be('alert always fire group all documents met threshold'); - const messagePattern = /alert always fire group all documents value \d+ exceeded threshold count > -1 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + const messagePattern = /alert 'always fire' is active for group \'all documents\':\n\n- Value: \d+\n- Conditions Met: count is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; expect(message).to.match(messagePattern); } }); @@ -134,7 +132,7 @@ export default function alertTests({ getService }: FtrProviderContext) { expect(name).to.be('always fire'); if (group === 'group-0') inGroup0++; - const messagePattern = /alert always fire group group-\d value \d+ exceeded threshold count .+ over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + const messagePattern = /alert 'always fire' is active for group \'group-\d\':\n\n- Value: \d+\n- Conditions Met: count is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; expect(message).to.match(messagePattern); } @@ -171,7 +169,7 @@ export default function alertTests({ getService }: FtrProviderContext) { expect(name).to.be('always fire'); - const messagePattern = /alert always fire group all documents value \d+ exceeded threshold sum\(testedValue\) between 0,1000000 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + const messagePattern = /alert 'always fire' is active for group \'all documents\':\n\n- Value: \d+\n- Conditions Met: sum\(testedValue\) is between 0 and 1000000 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; expect(message).to.match(messagePattern); } }); @@ -206,7 +204,7 @@ export default function alertTests({ getService }: FtrProviderContext) { expect(name).to.be('always fire'); - const messagePattern = /alert always fire group all documents value .+ exceeded threshold avg\(testedValue\) .+ 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + const messagePattern = /alert 'always fire' is active for group \'all documents\':\n\n- Value: .*\n- Conditions Met: avg\(testedValue\) is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; expect(message).to.match(messagePattern); } }); @@ -247,7 +245,7 @@ export default function alertTests({ getService }: FtrProviderContext) { expect(name).to.be('always fire'); if (group === 'group-2') inGroup2++; - const messagePattern = /alert always fire group group-. value \d+ exceeded threshold max\(testedValue\) .* 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + const messagePattern = /alert 'always fire' is active for group \'group-\d\':\n\n- Value: \d+\n- Conditions Met: max\(testedValue\) is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; expect(message).to.match(messagePattern); } @@ -292,7 +290,7 @@ export default function alertTests({ getService }: FtrProviderContext) { expect(name).to.be('always fire'); if (group === 'group-0') inGroup0++; - const messagePattern = /alert always fire group group-. value \d+ exceeded threshold min\(testedValue\) .* 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + const messagePattern = /alert 'always fire' is active for group \'group-\d\':\n\n- Value: \d+\n- Conditions Met: min\(testedValue\) is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; expect(message).to.match(messagePattern); } @@ -345,7 +343,7 @@ export default function alertTests({ getService }: FtrProviderContext) { name: '{{{alertName}}}', value: '{{{context.value}}}', title: '{{{context.title}}}', - message: DefaultActionMessage, + message: '{{{context.message}}}', }, date: '{{{context.date}}}', // TODO: I wanted to write the alert value here, but how? diff --git a/x-pack/test/api_integration/apis/management/advanced_settings/feature_controls.ts b/x-pack/test/api_integration/apis/management/advanced_settings/feature_controls.ts index 7a0d0fe2f5d487..d4dae769662d26 100644 --- a/x-pack/test/api_integration/apis/management/advanced_settings/feature_controls.ts +++ b/x-pack/test/api_integration/apis/management/advanced_settings/feature_controls.ts @@ -13,6 +13,27 @@ export default function featureControlsTests({ getService }: FtrProviderContext) const supertest: SuperTest = getService('supertestWithoutAuth'); const security = getService('security'); const spaces = getService('spaces'); + const deployment = getService('deployment'); + + async function expectTelemetryResponse(result: any, expectSuccess: boolean) { + if ((await deployment.isCloud()) === true) { + // Cloud deployments don't allow to change the opt-in status + expectTelemetryCloud400(result); + } else { + if (expectSuccess === true) { + expectResponse(result); + } else { + expect403(result); + } + } + } + + const expectTelemetryCloud400 = (result: any) => { + expect(result.error).to.be(undefined); + expect(result.response).not.to.be(undefined); + expect(result.response).to.have.property('statusCode', 400); + expect(result.response.body.message).to.be('{"error":"Not allowed to change Opt-in Status."}'); + }; const expect403 = (result: any) => { expect(result.error).to.be(undefined); @@ -21,16 +42,9 @@ export default function featureControlsTests({ getService }: FtrProviderContext) }; const expectResponse = (result: any) => { - if (result.response && result.response.statusCode === 400) { - // expect a change of telemetry settings to fail in cloud environment - expect(result.response.body.message).to.be( - '{"error":"Not allowed to change Opt-in Status."}' - ); - } else { - expect(result.error).to.be(undefined); - expect(result.response).not.to.be(undefined); - expect(result.response).to.have.property('statusCode', 200); - } + expect(result.error).to.be(undefined); + expect(result.response).not.to.be(undefined); + expect(result.response).to.have.property('statusCode', 200); }; async function saveAdvancedSetting(username: string, password: string, spaceId?: string) { @@ -83,7 +97,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expectResponse(regularSettingResult); const telemetryResult = await saveTelemetrySetting(username, password); - expectResponse(telemetryResult); + expectTelemetryResponse(telemetryResult, true); } finally { await security.role.delete(roleName); await security.user.delete(username); @@ -115,7 +129,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expect403(regularSettingResult); const telemetryResult = await saveTelemetrySetting(username, password); - expect403(telemetryResult); + expectTelemetryResponse(telemetryResult, false); } finally { await security.role.delete(roleName); await security.user.delete(username); @@ -189,7 +203,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expectResponse(regularSettingResult); const telemetryResult = await saveTelemetrySetting(username, password, space1Id); - expectResponse(telemetryResult); + expectTelemetryResponse(telemetryResult, true); }); it(`user_1 can only save telemetry in space_2`, async () => { @@ -197,7 +211,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expect403(regularSettingResult); const telemetryResult = await saveTelemetrySetting(username, password, space2Id); - expectResponse(telemetryResult); + expectTelemetryResponse(telemetryResult, true); }); it(`user_1 can't save either settings or telemetry in space_3`, async () => { @@ -205,7 +219,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expect403(regularSettingResult); const telemetryResult = await saveTelemetrySetting(username, password, space3Id); - expect403(telemetryResult); + expectTelemetryResponse(telemetryResult, false); }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 06d33da8f1f555..c9048eda45fe84 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -689,7 +689,7 @@ export const waitFor = async ( maxTimeout: number = 10000, timeoutWait: number = 10 ): Promise => { - await new Promise(async (resolve, reject) => { + await new Promise(async (resolve, reject) => { let found = false; let numberOfTries = 0; while (!found && numberOfTries < Math.floor(maxTimeout / timeoutWait)) { diff --git a/x-pack/test/functional/apps/lens/dashboard.ts b/x-pack/test/functional/apps/lens/dashboard.ts index c24f4ccf01bcd5..17b70b8510f04e 100644 --- a/x-pack/test/functional/apps/lens/dashboard.ts +++ b/x-pack/test/functional/apps/lens/dashboard.ts @@ -36,7 +36,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await PageObjects.common.navigateToApp('dashboard'); await security.testUser.setRoles( - ['global_dashboard_all', 'global_discover_all', 'test_logstash_reader'], + [ + 'global_dashboard_all', + 'global_discover_all', + 'test_logstash_reader', + 'global_visualize_all', + ], false ); }); @@ -116,6 +121,24 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); const hasGeoDestFilter = await filterBar.hasFilter('geo.dest', 'LS'); expect(hasGeoDestFilter).to.be(true); + await filterBar.addFilter('geo.src', 'is', 'US'); + await filterBar.toggleFilterPinned('geo.src'); + }); + + it('should not carry over filters if creating a new lens visualization from within dashboard', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await filterBar.addFilter('geo.src', 'is', 'US'); + await filterBar.toggleFilterPinned('geo.src'); + await filterBar.addFilter('geo.dest', 'is', 'LS'); + + await dashboardAddPanel.clickCreateNewLink(); + await dashboardAddPanel.clickVisType('lens'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const hasGeoDestFilter = await filterBar.hasFilter('geo.dest', 'LS'); + expect(hasGeoDestFilter).to.be(false); + const hasGeoSrcFilter = await filterBar.hasFilter('geo.src', 'US', true, true); + expect(hasGeoSrcFilter).to.be(true); }); }); } diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 29b42230673c91..b91399a4a6756d 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -330,5 +330,26 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.switchFirstLayerIndexPattern('log*'); expect(await PageObjects.lens.getFirstLayerIndexPattern()).to.equal('log*'); }); + + it('should show a download button only when the configuration is valid', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('pie'); + await PageObjects.lens.configureDimension({ + dimension: 'lnsPie_sliceByDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + // incomplete configuration should not be downloadable + expect(await testSubjects.isEnabled('lnsApp_downloadCSVButton')).to.eql(false); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsPie_sizeByDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + expect(await testSubjects.isEnabled('lnsApp_downloadCSVButton')).to.eql(true); + }); }); } diff --git a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts index b6b38b840677fa..1da696c30dfb7f 100644 --- a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts +++ b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts @@ -7,7 +7,6 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const security = getService('security'); const PageObjects = getPageObjects(['common', 'error', 'maps', 'settings', 'security']); const appsMenu = getService('appsMenu'); @@ -17,13 +16,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const savedQueryManagementComponent = getService('savedQueryManagementComponent'); describe('maps security feature controls', () => { - before(async () => { - await esArchiver.loadIfNeeded('maps/data'); - await esArchiver.load('maps/kibana'); - }); - after(async () => { - await esArchiver.unload('maps/kibana'); // logout, so the other tests don't accidentally run as the custom users we're testing below await PageObjects.security.forceLogout(); }); diff --git a/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts b/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts index 11b87469e49934..98f91705f0c1e8 100644 --- a/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts +++ b/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts @@ -7,7 +7,6 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const spacesService = getService('spaces'); const PageObjects = getPageObjects(['common', 'maps', 'security']); const appsMenu = getService('appsMenu'); @@ -15,13 +14,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { // FLAKY: https://github.com/elastic/kibana/issues/38414 describe.skip('spaces feature controls', () => { before(async () => { - await esArchiver.loadIfNeeded('maps/data'); - await esArchiver.load('maps/kibana'); PageObjects.maps.setBasePath('/s/custom_space'); }); after(async () => { - await esArchiver.unload('maps/kibana'); + await PageObjects.security.forceLogout(); PageObjects.maps.setBasePath(''); }); diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index 2d2d2f9d3cf9b8..a3cf85fd00d8e6 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -27,7 +27,7 @@ export default function ({ loadTestFile, getService }) { await esArchiver.unload('maps/kibana'); }); - describe('', function () { + describe('', async function () { this.tags('ciGroup9'); loadTestFile(require.resolve('./documents_source')); loadTestFile(require.resolve('./blended_vector_layer')); diff --git a/x-pack/test/functional/apps/monitoring/setup/metricbeat_migration.js b/x-pack/test/functional/apps/monitoring/setup/metricbeat_migration.js index 95bd866d386b1d..8a0c4216dfbd44 100644 --- a/x-pack/test/functional/apps/monitoring/setup/metricbeat_migration.js +++ b/x-pack/test/functional/apps/monitoring/setup/metricbeat_migration.js @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { getLifecycleMethods } from '../_get_lifecycle_methods'; export default function ({ getService, getPageObjects }) { + const deployment = getService('deployment'); const setupMode = getService('monitoringSetupMode'); const PageObjects = getPageObjects(['common', 'console']); @@ -39,7 +40,7 @@ export default function ({ getService, getPageObjects }) { }); it('should not show metricbeat migration if cloud', async () => { - const isCloud = await PageObjects.common.isCloud(); + const isCloud = await deployment.isCloud(); expect(await setupMode.doesMetricbeatMigrationTooltipAppear()).to.be(!isCloud); }); diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index da5b55f4aa2a1f..4c523ec5706e1c 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -37,6 +37,7 @@ import { RoleMappingsPageProvider } from './role_mappings_page'; import { SpaceSelectorPageProvider } from './space_selector_page'; import { IngestPipelinesPageProvider } from './ingest_pipelines_page'; import { TagManagementPageProvider } from './tag_management_page'; +import { NavigationalSearchProvider } from './navigational_search'; // just like services, PageObjects are defined as a map of // names to Providers. Merge in Kibana's or pick specific ones @@ -72,4 +73,5 @@ export const pageObjects = { lens: LensPageProvider, roleMappings: RoleMappingsPageProvider, ingestPipelines: IngestPipelinesPageProvider, + navigationalSearch: NavigationalSearchProvider, }; diff --git a/x-pack/test/functional/page_objects/navigational_search.ts b/x-pack/test/functional/page_objects/navigational_search.ts new file mode 100644 index 00000000000000..77df829e31019e --- /dev/null +++ b/x-pack/test/functional/page_objects/navigational_search.ts @@ -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 { WebElementWrapper } from '../../../../test/functional/services/lib/web_element_wrapper'; +import { FtrProviderContext } from '../ftr_provider_context'; + +interface SearchResult { + label: string; +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function NavigationalSearchProvider({ getService, getPageObjects }: FtrProviderContext) { + const find = getService('find'); + const testSubjects = getService('testSubjects'); + + class NavigationalSearch { + async focus() { + const field = await testSubjects.find('nav-search-input'); + await field.click(); + } + + async blur() { + await testSubjects.click('helpMenuButton'); + await testSubjects.click('helpMenuButton'); + await find.waitForDeletedByCssSelector('.navSearch__panel'); + } + + async searchFor( + term: string, + { clear = true, wait = true }: { clear?: boolean; wait?: boolean } = {} + ) { + if (clear) { + await this.clearField(); + } + const field = await testSubjects.find('nav-search-input'); + await field.type(term); + if (wait) { + await this.waitForResultsLoaded(); + } + } + + async clearField() { + const field = await testSubjects.find('nav-search-input'); + await field.clearValueWithKeyboard(); + } + + async isPopoverDisplayed() { + return await find.existsByCssSelector('.navSearch__panel'); + } + + async clickOnOption(index: number) { + const options = await testSubjects.findAll('nav-search-option'); + await options[index].click(); + } + + async waitForResultsLoaded(waitUntil: number = 3000) { + await testSubjects.exists('nav-search-option'); + // results are emitted in multiple batches. Each individual batch causes a re-render of + // the component, causing the current elements to become stale. We can't perform DOM access + // without heavy flakiness in this situation. + // there is NO ui indication of any kind to detect when all the emissions are done, + // so we are forced to fallback to awaiting a given amount of time once the first options are displayed. + await delay(waitUntil); + } + + async getDisplayedResults() { + const resultElements = await testSubjects.findAll('nav-search-option'); + return Promise.all(resultElements.map((el) => this.convertResultElement(el))); + } + + async isNoResultsPlaceholderDisplayed(checkAfter: number = 3000) { + // see comment in `waitForResultsLoaded` + await delay(checkAfter); + return testSubjects.exists('nav-search-no-results'); + } + + private async convertResultElement(resultEl: WebElementWrapper): Promise { + const labelEl = await find.allDescendantDisplayedByCssSelector( + '.euiSelectableTemplateSitewide__listItemTitle', + resultEl + ); + const label = await labelEl[0].getVisibleText(); + + return { + label, + }; + } + } + + return new NavigationalSearch(); +} diff --git a/x-pack/test/functional/page_objects/security_page.ts b/x-pack/test/functional/page_objects/security_page.ts index ef80ab475cbd67..aca37d3d058e7d 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.ts @@ -18,6 +18,7 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider const userMenu = getService('userMenu'); const comboBox = getService('comboBox'); const supertest = getService('supertestWithoutAuth'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['common', 'header', 'error']); interface LoginOptions { @@ -248,7 +249,7 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider } log.debug('Redirecting to /logout to force the logout'); - const url = PageObjects.common.getHostPort() + '/logout'; + const url = deployment.getHostPort() + '/logout'; await browser.get(url); log.debug('Waiting on the login form to appear'); await waitForLoginPage(); diff --git a/x-pack/test/functional/page_objects/status_page.ts b/x-pack/test/functional/page_objects/status_page.ts index 08726e1320f29a..c24b97b2b4feb9 100644 --- a/x-pack/test/functional/page_objects/status_page.ts +++ b/x-pack/test/functional/page_objects/status_page.ts @@ -7,12 +7,12 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; -export function StatusPagePageProvider({ getService, getPageObjects }: FtrProviderContext) { +export function StatusPagePageProvider({ getService }: FtrProviderContext) { const retry = getService('retry'); const log = getService('log'); const browser = getService('browser'); const find = getService('find'); - const { common } = getPageObjects(['common']); + const deployment = getService('deployment'); class StatusPage { async initTests() { @@ -21,7 +21,7 @@ export function StatusPagePageProvider({ getService, getPageObjects }: FtrProvid async navigateToPage() { return await retry.try(async () => { - const url = common.getHostPort() + '/status'; + const url = deployment.getHostPort() + '/status'; log.info(`StatusPage:navigateToPage(): ${url}`); await browser.get(url); }); diff --git a/x-pack/test/functional/services/monitoring/no_data.js b/x-pack/test/functional/services/monitoring/no_data.js index 9cb383c8c62591..f43f6cb4209c33 100644 --- a/x-pack/test/functional/services/monitoring/no_data.js +++ b/x-pack/test/functional/services/monitoring/no_data.js @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export function MonitoringNoDataProvider({ getService, getPageObjects }) { +export function MonitoringNoDataProvider({ getService }) { + const deployment = getService('deployment'); const testSubjects = getService('testSubjects'); const retry = getService('retry'); - const PageObjects = getPageObjects(['common']); return new (class NoData { async enableMonitoring() { @@ -15,7 +15,7 @@ export function MonitoringNoDataProvider({ getService, getPageObjects }) { // so the UI does not give the user a choice between the two collection // methods. So if we're on cloud, do not try and switch to internal collection // as it's already the default - if (!(await PageObjects.common.isCloud())) { + if (!(await deployment.isCloud())) { await testSubjects.click('useInternalCollection'); } await testSubjects.click('enableCollectionEnabled'); diff --git a/x-pack/test/functional_enterprise_search/services/app_search_client.ts b/x-pack/test/functional_enterprise_search/services/app_search_client.ts index fbd15b83f97ea7..f85ecd53903aef 100644 --- a/x-pack/test/functional_enterprise_search/services/app_search_client.ts +++ b/x-pack/test/functional_enterprise_search/services/app_search_client.ts @@ -104,7 +104,7 @@ const search = async (engineName: string): Promise => { // Since the App Search API does not issue document receipts, the only way to tell whether or not documents // are fully indexed is to poll the search endpoint. export const waitForIndexedDocs = (engineName: string) => { - return new Promise(async function (resolve) { + return new Promise(async function (resolve) { let isReady = false; while (!isReady) { const response = await search(engineName); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 7bcfca50e3c128..ea9441a2e788b6 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -81,7 +81,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(createdConnectorToastTitle).to.eql(`Created '${slackConnectorName}'`); const messageTextArea = await find.byCssSelector('[data-test-subj="messageTextArea"]'); expect(await messageTextArea.getAttribute('value')).to.eql( - 'alert {{alertName}} group {{context.group}} value {{context.value}} exceeded threshold {{context.function}} over {{params.timeWindowSize}}{{params.timeWindowUnit}} on {{context.date}}' + `alert '{{alertName}}' is active for group '{{context.group}}': + +- Value: {{context.value}} +- Conditions Met: {{context.conditions}} over {{params.timeWindowSize}}{{params.timeWindowUnit}} +- Timestamp: {{context.date}}` ); await testSubjects.setValue('messageTextArea', 'test message '); await testSubjects.click('messageAddVariableButton'); diff --git a/x-pack/test/lists_api_integration/utils.ts b/x-pack/test/lists_api_integration/utils.ts index 224048e868d7f0..53472b459b8acd 100644 --- a/x-pack/test/lists_api_integration/utils.ts +++ b/x-pack/test/lists_api_integration/utils.ts @@ -115,7 +115,7 @@ export const waitFor = async ( maxTimeout: number = 5000, timeoutWait: number = 10 ) => { - await new Promise(async (resolve, reject) => { + await new Promise(async (resolve, reject) => { let found = false; let numberOfTries = 0; while (!found && numberOfTries < Math.floor(maxTimeout / timeoutWait)) { diff --git a/x-pack/test/plugin_functional/config.ts b/x-pack/test/plugin_functional/config.ts index cb0b9f63906ce8..600c598fc6bdf1 100644 --- a/x-pack/test/plugin_functional/config.ts +++ b/x-pack/test/plugin_functional/config.ts @@ -5,6 +5,7 @@ */ import { resolve } from 'path'; import fs from 'fs'; +import { KIBANA_ROOT } from '@kbn/test'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { services } from './services'; import { pageObjects } from './page_objects'; @@ -39,6 +40,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xpackFunctionalConfig.get('kbnTestServer'), serverArgs: [ ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${resolve( + KIBANA_ROOT, + 'test/plugin_functional/plugins/core_provider_plugin' + )}`, ...plugins.map((pluginDir) => `--plugin-path=${resolve(__dirname, 'plugins', pluginDir)}`), ], }, diff --git a/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/data.json b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/data.json new file mode 100644 index 00000000000000..69220756639dcf --- /dev/null +++ b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/data.json @@ -0,0 +1,358 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "name": "Default Space" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-1", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-1", + "description": "My first tag!", + "color": "#FF00FF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-2", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-2", + "description": "Another awesome tag", + "color": "#11FF22" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-3", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-3", + "description": "Last but not least", + "color": "#AA0077" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-4", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-4", + "description": "Last", + "color": "#AA0077" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:logstash-*", + "index": ".kibana", + "source": { + "index-pattern": { + "fieldFormatMap": "{\"bytes\":{\"id\":\"bytes\"}}", + "fields": "[{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false}]", + "timeFieldName": "@timestamp", + "title": "logstash-*" + }, + "type": "index-pattern" + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-1", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 1 (tag-1)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-1", + "name": "tag-1-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-2", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 2 (tag-2)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-2", + "name": "tag-2-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-3", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 3 (tag-1 + tag-3)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-1", + "name": "tag-1-ref" + }, + { "type": "tag", + "id": "tag-3", + "name": "tag-3-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-4", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 4 (tag-2)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-2", + "name": "tag-2-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-5", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "My awesome vis (tag-4)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-4", + "name": "tag-4-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-2", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 1 (tag-2)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-2", + "name": "tag-2-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-3", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 2 (tag-3)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-3", + "name": "tag-3-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-1-and-tag-3", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 3 (tag-1 and tag-3)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-1", + "name": "tag-1-ref", + "type": "tag" + }, + { + "id": "tag-3", + "name": "tag-3-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} diff --git a/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/mappings.json b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/mappings.json new file mode 100644 index 00000000000000..ec28b51de1d10c --- /dev/null +++ b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/mappings.json @@ -0,0 +1,266 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "dynamic": "strict", + "properties": { + "migrationVersion": { + "dynamic": "true", + "properties": { + "dashboard": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "search": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "visualization": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "tag": { + "properties": { + "name": { + "type": "text" + }, + "description": { + "type": "text" + }, + "color": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json b/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json index 934c6cce633870..e081b47760b996 100644 --- a/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json +++ b/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json @@ -4,6 +4,6 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "global_search_test"], "requiredPlugins": ["globalSearch"], - "server": true, + "server": false, "ui": true } diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts b/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts index aba3512788f9c3..4e5adee4bce9c8 100644 --- a/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts +++ b/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { of } from 'rxjs'; import { map, reduce } from 'rxjs/operators'; import { Plugin, CoreSetup, CoreStart, AppMountParameters } from 'kibana/public'; import { @@ -12,13 +11,11 @@ import { GlobalSearchPluginStart, GlobalSearchResult, } from '../../../../../plugins/global_search/public'; -import { createResult } from '../common/utils'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface GlobalSearchTestPluginSetup {} export interface GlobalSearchTestPluginStart { - findTest: (term: string) => Promise; - findReal: (term: string) => Promise; + find: (term: string) => Promise; } export interface GlobalSearchTestPluginSetupDeps { @@ -48,25 +45,6 @@ export class GlobalSearchTestPlugin }, }); - globalSearch.registerResultProvider({ - id: 'gs_test_client', - find: (term, options) => { - if (term.includes('client')) { - return of([ - createResult({ - id: 'client1', - type: 'test_client_type', - }), - createResult({ - id: 'client2', - type: 'test_client_type', - }), - ]); - } - return of([]); - }, - }); - return {}; } @@ -75,23 +53,11 @@ export class GlobalSearchTestPlugin { globalSearch }: GlobalSearchTestPluginStartDeps ): GlobalSearchTestPluginStart { return { - findTest: (term) => - globalSearch - .find(term, {}) - .pipe( - map((batch) => batch.results), - // restrict to test type to avoid failure when real providers are present - map((results) => results.filter((r) => r.type.startsWith('test_'))), - reduce((memo, results) => [...memo, ...results]) - ) - .toPromise(), - findReal: (term) => + find: (term) => globalSearch - .find(term, {}) + .find({ term }, {}) .pipe( map((batch) => batch.results), - // remove test types - map((results) => results.filter((r) => !r.type.startsWith('test_'))), reduce((memo, results) => [...memo, ...results]) ) .toPromise(), diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts b/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts deleted file mode 100644 index 7f9cdf423718b2..00000000000000 --- a/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { PluginInitializer } from 'src/core/server'; -import { - GlobalSearchTestPlugin, - GlobalSearchTestPluginSetup, - GlobalSearchTestPluginStart, - GlobalSearchTestPluginSetupDeps, - GlobalSearchTestPluginStartDeps, -} from './plugin'; - -export const plugin: PluginInitializer< - GlobalSearchTestPluginSetup, - GlobalSearchTestPluginStart, - GlobalSearchTestPluginSetupDeps, - GlobalSearchTestPluginStartDeps -> = () => new GlobalSearchTestPlugin(); diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts b/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts deleted file mode 100644 index d8ad94ab74207e..00000000000000 --- a/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts +++ /dev/null @@ -1,61 +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 { of } from 'rxjs'; -import { Plugin, CoreSetup, CoreStart } from 'kibana/server'; -import { - GlobalSearchPluginSetup, - GlobalSearchPluginStart, -} from '../../../../../plugins/global_search/server'; -import { createResult } from '../common/utils'; - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface GlobalSearchTestPluginSetup {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface GlobalSearchTestPluginStart {} - -export interface GlobalSearchTestPluginSetupDeps { - globalSearch: GlobalSearchPluginSetup; -} -export interface GlobalSearchTestPluginStartDeps { - globalSearch: GlobalSearchPluginStart; -} - -export class GlobalSearchTestPlugin - implements - Plugin< - GlobalSearchTestPluginSetup, - GlobalSearchTestPluginStart, - GlobalSearchTestPluginSetupDeps, - GlobalSearchTestPluginStartDeps - > { - public setup(core: CoreSetup, { globalSearch }: GlobalSearchTestPluginSetupDeps) { - globalSearch.registerResultProvider({ - id: 'gs_test_server', - find: (term, options, context) => { - if (term.includes('server')) { - return of([ - createResult({ - id: 'server1', - type: 'test_server_type', - }), - createResult({ - id: 'server2', - type: 'test_server_type', - }), - ]); - } - return of([]); - }, - }); - - return {}; - } - - public start(core: CoreStart) { - return {}; - } -} diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts deleted file mode 100644 index 146c4297fc2c8f..00000000000000 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; -import { GlobalSearchResult } from '../../../../plugins/global_search/common/types'; -import { GlobalSearchTestApi } from '../../plugins/global_search_test/public/types'; - -export default function ({ getPageObjects, getService }: FtrProviderContext) { - const pageObjects = getPageObjects(['common']); - const browser = getService('browser'); - - const findResultsWithAPI = async (t: string): Promise => { - return browser.executeAsync(async (term, cb) => { - const { start } = window._coreProvider; - const globalSearchTestApi: GlobalSearchTestApi = start.plugins.globalSearchTest; - globalSearchTestApi.findTest(term).then(cb); - }, t); - }; - - describe('GlobalSearch API', function () { - beforeEach(async function () { - await pageObjects.common.navigateToApp('globalSearchTestApp'); - }); - - it('return no results when no provider return results', async () => { - const results = await findResultsWithAPI('no_match'); - expect(results.length).to.be(0); - }); - it('return results from the client provider', async () => { - const results = await findResultsWithAPI('client'); - expect(results.length).to.be(2); - expect(results.map((r) => r.id)).to.eql(['client1', 'client2']); - }); - it('return results from the server provider', async () => { - const results = await findResultsWithAPI('server'); - expect(results.length).to.be(2); - expect(results.map((r) => r.id)).to.eql(['server1', 'server2']); - }); - it('return mixed results from both client and server providers', async () => { - const results = await findResultsWithAPI('server+client'); - expect(results.length).to.be(4); - expect(results.map((r) => r.id)).to.eql(['client1', 'client2', 'server1', 'server2']); - }); - }); -} diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts index 005d516e2943cf..97d50bda899fde 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts @@ -8,33 +8,149 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - // See: https://github.com/elastic/kibana/issues/81397 - describe.skip('GlobalSearchBar', function () { - const { common } = getPageObjects(['common']); - const find = getService('find'); - const testSubjects = getService('testSubjects'); + describe('GlobalSearchBar', function () { + const { common, navigationalSearch } = getPageObjects(['common', 'navigationalSearch']); + const esArchiver = getService('esArchiver'); const browser = getService('browser'); before(async () => { + await esArchiver.load('global_search/search_syntax'); await common.navigateToApp('home'); }); - it('basically works', async () => { - const field = await testSubjects.find('header-search'); - await field.click(); + after(async () => { + await esArchiver.unload('global_search/search_syntax'); + }); - expect((await testSubjects.findAll('header-search-option')).length).to.be(15); + afterEach(async () => { + await navigationalSearch.blur(); + }); - field.type('d'); + it('shows the popover on focus', async () => { + await navigationalSearch.focus(); - const options = await testSubjects.findAll('header-search-option'); + expect(await navigationalSearch.isPopoverDisplayed()).to.eql(true); - expect(options.length).to.be(6); + await navigationalSearch.blur(); - await options[1].click(); + expect(await navigationalSearch.isPopoverDisplayed()).to.eql(false); + }); + + it('redirects to the correct page', async () => { + await navigationalSearch.searchFor('type:application discover'); + await navigationalSearch.clickOnOption(0); expect(await browser.getCurrentUrl()).to.contain('discover'); - expect(await (await find.activeElement()).getTagName()).to.be('body'); + }); + + describe('advanced search syntax', () => { + it('allows to filter by type', async () => { + await navigationalSearch.searchFor('type:dashboard'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'dashboard 1 (tag-2)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by multiple types', async () => { + await navigationalSearch.searchFor('type:(dashboard OR visualization)'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 2 (tag-2)', + 'Visualization 3 (tag-1 + tag-3)', + 'Visualization 4 (tag-2)', + 'My awesome vis (tag-4)', + 'dashboard 1 (tag-2)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by tag', async () => { + await navigationalSearch.searchFor('tag:tag-1'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by multiple tags', async () => { + await navigationalSearch.searchFor('tag:tag-1 tag:tag-3'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by type and tag', async () => { + await navigationalSearch.searchFor('type:dashboard tag:tag-3'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by multiple types and tags', async () => { + await navigationalSearch.searchFor( + 'type:(dashboard OR visualization) tag:(tag-1 OR tag-3)' + ); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by term and type', async () => { + await navigationalSearch.searchFor('type:visualization awesome'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql(['My awesome vis (tag-4)']); + }); + + it('allows to filter by term and tag', async () => { + await navigationalSearch.searchFor('tag:tag-4 awesome'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql(['My awesome vis (tag-4)']); + }); + + it('returns no results when searching for an unknown tag', async () => { + await navigationalSearch.searchFor('tag:unknown'); + + expect(await navigationalSearch.isNoResultsPlaceholderDisplayed()).to.eql(true); + }); + + it('returns no results when searching for an unknown type', async () => { + await navigationalSearch.searchFor('type:unknown'); + + expect(await navigationalSearch.isNoResultsPlaceholderDisplayed()).to.eql(true); + }); }); }); } diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts index 4b5b372c926410..16dc7b379214a0 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts @@ -18,7 +18,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { return browser.executeAsync(async (term, cb) => { const { start } = window._coreProvider; const globalSearchTestApi: GlobalSearchTestApi = start.plugins.globalSearchTest; - globalSearchTestApi.findReal(term).then(cb); + globalSearchTestApi.find(term).then(cb); }, t); }; diff --git a/x-pack/test/plugin_functional/test_suites/global_search/index.ts b/x-pack/test/plugin_functional/test_suites/global_search/index.ts index f43e293c30fd6b..f3557ee8cc8dbe 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/index.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/index.ts @@ -7,10 +7,8 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - // See https://github.com/elastic/kibana/issues/81397 - describe.skip('GlobalSearch API', function () { - this.tags('ciGroup7'); - loadTestFile(require.resolve('./global_search_api')); + describe('GlobalSearch API', function () { + this.tags('ciGroup10'); loadTestFile(require.resolve('./global_search_providers')); loadTestFile(require.resolve('./global_search_bar')); }); diff --git a/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts b/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts index ced9598809e108..4552df4cf2b38c 100644 --- a/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts +++ b/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts @@ -46,7 +46,7 @@ export default function ({ getService }: FtrProviderContext) { // JSDOM doesn't support changing of `window.location` and throws an exception if script // tries to do that and we have to workaround this behaviour. We also need to wait until our // script is loaded and executed, __isScriptExecuted__ is used exactly for that. - (window as Record).__isScriptExecuted__ = new Promise((resolve) => { + (window as Record).__isScriptExecuted__ = new Promise((resolve) => { Object.defineProperty(window, 'location', { value: { href: diff --git a/x-pack/test/security_api_integration/tests/token/header.ts b/x-pack/test/security_api_integration/tests/token/header.ts index 2150d7a6269b0c..53b50286cc6cc7 100644 --- a/x-pack/test/security_api_integration/tests/token/header.ts +++ b/x-pack/test/security_api_integration/tests/token/header.ts @@ -66,7 +66,7 @@ export default function ({ getService }: FtrProviderContext) { // Access token expiration is set to 15s for API integration tests. // Let's wait for 20s to make sure token expires. - await new Promise((resolve) => setTimeout(() => resolve(), 20000)); + await new Promise((resolve) => setTimeout(resolve, 20000)); await supertest .get('/internal/security/me') diff --git a/x-pack/test/security_api_integration/tests/token/session.ts b/x-pack/test/security_api_integration/tests/token/session.ts index 30e004a0fff3c5..daee8264bd0bdb 100644 --- a/x-pack/test/security_api_integration/tests/token/session.ts +++ b/x-pack/test/security_api_integration/tests/token/session.ts @@ -8,7 +8,7 @@ import request, { Cookie } from 'request'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; -const delay = (ms: number) => new Promise((resolve) => setTimeout(() => resolve(), ms)); +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); diff --git a/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts b/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts index 8c208625590927..3e41fa9183305b 100644 --- a/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts +++ b/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const browser = getService('browser'); const security = getService('security'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['security', 'common']); describe('Authentication provider hint', function () { @@ -39,7 +40,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); beforeEach(async () => { - await browser.get(`${PageObjects.common.getHostPort()}/login`); + await browser.get(`${deployment.getHostPort()}/login`); await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible(); }); diff --git a/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts index a08fae4cdb0a13..fe2dca806a6c12 100644 --- a/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts +++ b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts @@ -13,6 +13,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const browser = getService('browser'); const security = getService('security'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['security', 'common']); describe('Basic functionality', function () { @@ -33,7 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); beforeEach(async () => { - await browser.get(`${PageObjects.common.getHostPort()}/login`); + await browser.get(`${deployment.getHostPort()}/login`); await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible(); }); diff --git a/x-pack/test/security_functional/tests/oidc/url_capture.ts b/x-pack/test/security_functional/tests/oidc/url_capture.ts index bb4917f18fc1ca..a1afcd1e760209 100644 --- a/x-pack/test/security_functional/tests/oidc/url_capture.ts +++ b/x-pack/test/security_functional/tests/oidc/url_capture.ts @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const find = getService('find'); const browser = getService('browser'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['common']); describe('URL capture', function () { @@ -31,13 +32,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); afterEach(async () => { - await browser.get(PageObjects.common.getHostPort() + '/logout'); + await browser.get(deployment.getHostPort() + '/logout'); await PageObjects.common.waitUntilUrlIncludes('logged_out'); }); it('can login preserving original URL', async () => { await browser.get( - PageObjects.common.getHostPort() + '/app/management/security/users#some=hash-value' + deployment.getHostPort() + '/app/management/security/users#some=hash-value' ); await find.byCssSelector( diff --git a/x-pack/test/security_functional/tests/saml/url_capture.ts b/x-pack/test/security_functional/tests/saml/url_capture.ts index 5d47d80efadcb0..cdb0be69422519 100644 --- a/x-pack/test/security_functional/tests/saml/url_capture.ts +++ b/x-pack/test/security_functional/tests/saml/url_capture.ts @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const find = getService('find'); const browser = getService('browser'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['common']); describe('URL capture', function () { @@ -31,13 +32,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); afterEach(async () => { - await browser.get(PageObjects.common.getHostPort() + '/logout'); + await browser.get(deployment.getHostPort() + '/logout'); await PageObjects.common.waitUntilUrlIncludes('logged_out'); }); it('can login preserving original URL', async () => { await browser.get( - PageObjects.common.getHostPort() + '/app/management/security/users#some=hash-value' + deployment.getHostPort() + '/app/management/security/users#some=hash-value' ); await find.byCssSelector( diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts index 2c59863099ae71..b4e98d7d4b95e3 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts @@ -5,16 +5,24 @@ */ import _ from 'lodash'; import expect from '@kbn/expect'; +import { firstNonNullValue } from '../../../../plugins/security_solution/common/endpoint/models/ecs_safety_helpers'; +import { + NodeID, + Schema, +} from '../../../../plugins/security_solution/server/endpoint/routes/resolver/tree/utils'; import { SafeResolverChildNode, SafeResolverLifecycleNode, SafeResolverEvent, ResolverNodeStats, + ResolverNode, } from '../../../../plugins/security_solution/common/endpoint/types'; import { parentEntityIDSafeVersion, entityIDSafeVersion, eventIDSafeVersion, + timestampSafeVersion, + timestampAsDateSafeVersion, } from '../../../../plugins/security_solution/common/endpoint/models/event'; import { Event, @@ -24,6 +32,344 @@ import { categoryMapping, } from '../../../../plugins/security_solution/common/endpoint/generate_data'; +const createLevels = ({ + descendantsByParent, + levels, + currentNodes, + schema, +}: { + descendantsByParent: Map>; + levels: Array>; + currentNodes: Map | undefined; + schema: Schema; +}): Array> => { + if (!currentNodes || currentNodes.size === 0) { + return levels; + } + levels.push(currentNodes); + const nextLevel: Map = new Map(); + for (const node of currentNodes.values()) { + const id = getID(node, schema); + const children = descendantsByParent.get(id); + if (children) { + for (const child of children.values()) { + const childID = getID(child, schema); + nextLevel.set(childID, child); + } + } + } + return createLevels({ descendantsByParent, levels, currentNodes: nextLevel, schema }); +}; + +interface TreeExpectation { + origin: NodeID; + nodeExpectations: NodeExpectations; +} + +interface NodeExpectations { + ancestors?: number; + descendants?: number; + descendantLevels?: number; +} + +interface APITree { + // entries closer to the beginning of the array are more direct parents of the origin aka + // ancestors[0] = the origin's parent, ancestors[1] = the origin's grandparent + ancestors: ResolverNode[]; + // if no ancestors were retrieved then the origin will be undefined + origin: ResolverNode | undefined; + descendantLevels: Array>; + nodeExpectations: NodeExpectations; +} + +/** + * Represents a utility structure for making it easier to perform expect calls on the response + * from the /tree api. This can represent multiple trees, since the tree api can return multiple trees. + */ +export interface APIResponse { + nodesByID: Map; + trees: Map; + allNodes: ResolverNode[]; +} + +/** + * Gets the ID field from a resolver node. Throws an error if the ID doesn't exist. + * + * @param node a resolver node + * @param schema the schema that was used to retrieve this resolver node + */ +export const getID = (node: ResolverNode | undefined, schema: Schema): NodeID => { + const id = firstNonNullValue(node?.data[schema.id]); + if (!id) { + throw new Error(`Unable to find id ${schema.id} in node: ${JSON.stringify(node)}`); + } + return id; +}; + +const getParentInternal = (node: ResolverNode | undefined, schema: Schema): NodeID | undefined => { + if (node) { + return firstNonNullValue(node?.data[schema.parent]); + } + return undefined; +}; + +/** + * Gets the parent ID field from a resolver node. Throws an error if the ID doesn't exist. + * + * @param node a resolver node + * @param schema the schema that was used to retrieve this resolver node + */ +export const getParent = (node: ResolverNode | undefined, schema: Schema): NodeID => { + const parent = getParentInternal(node, schema); + if (!parent) { + throw new Error(`Unable to find parent ${schema.parent} in node: ${JSON.stringify(node)}`); + } + return parent; +}; + +/** + * Reformats the tree's response to make it easier to perform testing on the results. + * + * @param treeExpectations the node IDs used to retrieve the trees and the expected number of ancestors/descendants in the + * resulting trees + * @param nodes the response from the tree api + * @param schema the schema used when calling the tree api + */ +const createTreeFromResponse = ( + treeExpectations: TreeExpectation[], + nodes: ResolverNode[], + schema: Schema +) => { + const nodesByID = new Map(); + const nodesByParent = new Map>(); + + for (const node of nodes) { + const id = getID(node, schema); + const parent = getParentInternal(node, schema); + + nodesByID.set(id, node); + + if (parent) { + let groupedChildren = nodesByParent.get(parent); + if (!groupedChildren) { + groupedChildren = new Map(); + nodesByParent.set(parent, groupedChildren); + } + + groupedChildren.set(id, node); + } + } + + const trees: Map = new Map(); + + for (const expectation of treeExpectations) { + const descendantLevels = createLevels({ + descendantsByParent: nodesByParent, + levels: [], + currentNodes: nodesByParent.get(expectation.origin), + schema, + }); + + const ancestors: ResolverNode[] = []; + const originNode = nodesByID.get(expectation.origin); + if (originNode) { + let currentID: NodeID | undefined = getParentInternal(originNode, schema); + // construct an array with all the ancestors from the response. We'll use this to verify that + // all the expected ancestors were returned in the response. + while (currentID !== undefined) { + const parentNode = nodesByID.get(currentID); + if (parentNode) { + ancestors.push(parentNode); + } + currentID = getParentInternal(parentNode, schema); + } + } + + trees.set(expectation.origin, { + ancestors, + origin: originNode, + descendantLevels, + nodeExpectations: expectation.nodeExpectations, + }); + } + + return { + nodesByID, + trees, + allNodes: nodes, + }; +}; + +const verifyAncestry = ({ + responseTrees, + schema, + genTree, +}: { + responseTrees: APIResponse; + schema: Schema; + genTree: Tree; +}) => { + const allGenNodes = new Map([ + ...genTree.ancestry, + ...genTree.children, + [genTree.origin.id, genTree.origin], + ]); + + for (const tree of responseTrees.trees.values()) { + if (tree.nodeExpectations.ancestors !== undefined) { + expect(tree.ancestors.length).to.be(tree.nodeExpectations.ancestors); + } + + if (tree.origin !== undefined) { + // make sure the origin node from the request exists in the generated data and has the same fields + const originID = getID(tree.origin, schema); + const originParentID = getParent(tree.origin, schema); + expect(tree.origin.id).to.be(originID); + expect(tree.origin.parent).to.be(originParentID); + expect(allGenNodes.get(String(originID))?.id).to.be(String(originID)); + expect(allGenNodes.get(String(originParentID))?.id).to.be(String(originParentID)); + expect(originID).to.be(entityIDSafeVersion(allGenNodes.get(String(originID))!.lifecycle[0])); + expect(originParentID).to.be( + parentEntityIDSafeVersion(allGenNodes.get(String(originID))!.lifecycle[0]) + ); + // make sure the lifecycle events are sorted by timestamp in ascending order because the + // event that will be returned that we need to compare to should be the earliest event + // found + const originLifecycleSorted = [...allGenNodes.get(String(originID))!.lifecycle].sort( + (a: Event, b: Event) => { + const aTime: number | undefined = timestampSafeVersion(a); + const bTime = timestampSafeVersion(b); + if (aTime !== undefined && bTime !== undefined) { + return aTime - bTime; + } else { + return 0; + } + } + ); + + const ts = timestampAsDateSafeVersion(tree.origin?.data); + expect(ts).to.not.be(undefined); + expect(ts).to.eql(timestampAsDateSafeVersion(originLifecycleSorted[0])); + } + + // check the constructed ancestors array to see if we're missing any nodes in the ancestry + for (let i = 0; i < tree.ancestors.length; i++) { + const id = getID(tree.ancestors[i], schema); + const parent = getParentInternal(tree.ancestors[i], schema); + // only compare to the parent if this is not the last entry in the array + if (i < tree.ancestors.length - 1) { + // the current node's parent ID should match the parent's ID field + expect(parent).to.be(getID(tree.ancestors[i + 1], schema)); + expect(parent).to.not.be(undefined); + expect(tree.ancestors[i].parent).to.not.be(undefined); + expect(tree.ancestors[i].parent).to.be(parent); + } + // the current node's ID must exist in the generated tree + expect(allGenNodes.get(String(id))?.id).to.be(id); + expect(tree.ancestors[i].id).to.be(id); + } + } +}; + +const verifyChildren = ({ + responseTrees, + schema, + genTree, +}: { + responseTrees: APIResponse; + schema: Schema; + genTree: Tree; +}) => { + const allGenNodes = new Map([ + ...genTree.ancestry, + ...genTree.children, + [genTree.origin.id, genTree.origin], + ]); + for (const tree of responseTrees.trees.values()) { + if (tree.nodeExpectations.descendantLevels !== undefined) { + expect(tree.nodeExpectations.descendantLevels).to.be(tree.descendantLevels.length); + } + let totalDescendants = 0; + + for (const level of tree.descendantLevels) { + for (const node of level.values()) { + totalDescendants += 1; + const id = getID(node, schema); + const parent = getParent(node, schema); + const genNode = allGenNodes.get(String(id)); + expect(id).to.be(node.id); + expect(parent).to.be(node.parent); + expect(node.parent).to.not.be(undefined); + // make sure the id field is the same in the returned node as the generated one + expect(id).to.be(entityIDSafeVersion(genNode!.lifecycle[0])); + // make sure the parent field is the same in the returned node as the generated one + expect(parent).to.be(parentEntityIDSafeVersion(genNode!.lifecycle[0])); + } + } + if (tree.nodeExpectations.descendants !== undefined) { + expect(tree.nodeExpectations.descendants).to.be(totalDescendants); + } + } +}; + +const verifyStats = ({ + responseTrees, + relatedEventsCategories, +}: { + responseTrees: APIResponse; + relatedEventsCategories: RelatedEventInfo[]; +}) => { + for (const node of responseTrees.allNodes) { + let totalExpEvents = 0; + for (const cat of relatedEventsCategories) { + const ecsCategories = categoryMapping[cat.category]; + if (Array.isArray(ecsCategories)) { + // if there are multiple ecs categories used to define a related event, the count for all of them should be the same + // and they should equal what is defined in the categories used to generate the related events + for (const ecsCat of ecsCategories) { + expect(node.stats.byCategory[ecsCat]).to.be(cat.count); + } + } else { + expect(node.stats.byCategory[ecsCategories]).to.be(cat.count); + } + + totalExpEvents += cat.count; + } + expect(node.stats.total).to.be(totalExpEvents); + } +}; + +/** + * Verify the ancestry of multiple trees. + * + * @param expectations array of expectations based on the origin that built a particular tree + * @param response the nodes returned from the api + * @param schema the schema fields passed to the tree api + * @param genTree the generated tree that was inserted in Elasticsearch that we are querying + * @param relatedEventsCategories an optional array to instruct the verification to check the stats + * on each node returned + */ +export const verifyTree = ({ + expectations, + response, + schema, + genTree, + relatedEventsCategories, +}: { + expectations: TreeExpectation[]; + response: ResolverNode[]; + schema: Schema; + genTree: Tree; + relatedEventsCategories?: RelatedEventInfo[]; +}) => { + const responseTrees = createTreeFromResponse(expectations, response, schema); + verifyAncestry({ responseTrees, schema, genTree }); + verifyChildren({ responseTrees, schema, genTree }); + if (relatedEventsCategories !== undefined) { + verifyStats({ responseTrees, relatedEventsCategories }); + } +}; + /** * Creates the ancestry array based on an array of events. The order of the ancestry array will match the order * of the events passed in. @@ -44,6 +390,7 @@ export const createAncestryArray = (events: Event[]) => { /** * Check that the given lifecycle is in the resolver tree's corresponding map * + * @deprecated use verifyTree * @param node a lifecycle node containing the start and end events for a node * @param nodeMap a map of entity_ids to nodes to look for the passed in `node` */ @@ -59,12 +406,13 @@ const expectLifecycleNodeInMap = ( /** * Verify that all the ancestor nodes are valid and optionally have parents. * + * @deprecated use verifyTree * @param ancestors an array of ancestors * @param tree the generated resolver tree as the source of truth * @param verifyLastParent a boolean indicating whether to check the last ancestor. If the ancestors array intentionally * does not contain all the ancestors, the last one will not have the parent */ -export const verifyAncestry = ( +export const checkAncestryFromEntityTreeAPI = ( ancestors: SafeResolverLifecycleNode[], tree: Tree, verifyLastParent: boolean @@ -114,6 +462,7 @@ export const verifyAncestry = ( /** * Retrieves the most distant ancestor in the given array. * + * @deprecated use verifyTree * @param ancestors an array of ancestor nodes */ export const retrieveDistantAncestor = (ancestors: SafeResolverLifecycleNode[]) => { @@ -137,12 +486,13 @@ export const retrieveDistantAncestor = (ancestors: SafeResolverLifecycleNode[]) /** * Verify that the children nodes are correct * + * @deprecated use verifyTree * @param children the children nodes * @param tree the generated resolver tree as the source of truth * @param numberOfParents an optional number to compare that are a certain number of parents in the children array * @param childrenPerParent an optional number to compare that there are a certain number of children for each parent */ -export const verifyChildren = ( +export const verifyChildrenFromEntityTreeAPI = ( children: SafeResolverChildNode[], tree: Tree, numberOfParents?: number, @@ -200,10 +550,11 @@ export const compareArrays = ( /** * Verifies that the stats received from ES for a node reflect the categories of events that the generator created. * + * @deprecated use verifyTree * @param relatedEvents the related events received for a particular node * @param categories the related event info used when generating the resolver tree */ -export const verifyStats = ( +export const verifyEntityTreeStats = ( stats: ResolverNodeStats | undefined, categories: RelatedEventInfo[], relatedAlerts: number @@ -225,12 +576,12 @@ export const verifyStats = ( totalExpEvents += cat.count; } expect(stats?.events.total).to.be(totalExpEvents); - expect(stats?.totalAlerts); }; /** * A helper function for verifying the stats information an array of nodes. * + * @deprecated use verifyTree * @param nodes an array of lifecycle nodes that should have a stats field defined * @param categories the related event info used when generating the resolver tree */ @@ -240,6 +591,6 @@ export const verifyLifecycleStats = ( relatedAlerts: number ) => { for (const node of nodes) { - verifyStats(node.stats, categories, relatedAlerts); + verifyEntityTreeStats(node.stats, categories, relatedAlerts); } }; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts index ecfc1ef5bb7f53..0ba5460f09d9dd 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts @@ -12,6 +12,7 @@ export default function (providerContext: FtrProviderContext) { loadTestFile(require.resolve('./entity_id')); loadTestFile(require.resolve('./entity')); loadTestFile(require.resolve('./children')); + loadTestFile(require.resolve('./tree_entity_id')); loadTestFile(require.resolve('./tree')); loadTestFile(require.resolve('./alerts')); loadTestFile(require.resolve('./events')); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts index 7a95bf7bab8836..646a666629ac9d 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts @@ -4,31 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ import expect from '@kbn/expect'; +import { getNameField } from '../../../../plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch'; +import { Schema } from '../../../../plugins/security_solution/server/endpoint/routes/resolver/tree/utils'; +import { ResolverNode } from '../../../../plugins/security_solution/common/endpoint/types'; import { - SafeResolverAncestry, - SafeResolverChildren, - SafeResolverTree, - SafeLegacyEndpointEvent, -} from '../../../../plugins/security_solution/common/endpoint/types'; -import { parentEntityIDSafeVersion } from '../../../../plugins/security_solution/common/endpoint/models/event'; + parentEntityIDSafeVersion, + timestampSafeVersion, +} from '../../../../plugins/security_solution/common/endpoint/models/event'; import { FtrProviderContext } from '../../ftr_provider_context'; import { Tree, RelatedEventCategory, } from '../../../../plugins/security_solution/common/endpoint/generate_data'; import { Options, GeneratedTrees } from '../../services/resolver'; -import { - compareArrays, - verifyAncestry, - retrieveDistantAncestor, - verifyChildren, - verifyLifecycleStats, - verifyStats, -} from './common'; +import { verifyTree } from './common'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); const resolver = getService('resolverGenerator'); const relatedEventsToGen = [ @@ -52,322 +44,641 @@ export default function ({ getService }: FtrProviderContext) { ancestryArraySize: 2, }; + const schemaWithAncestry: Schema = { + ancestry: 'process.Ext.ancestry', + id: 'process.entity_id', + parent: 'process.parent.entity_id', + }; + + const schemaWithoutAncestry: Schema = { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + }; + + const schemaWithName: Schema = { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + name: 'process.name', + }; + describe('Resolver tree', () => { before(async () => { - await esArchiver.load('endpoint/resolver/api_feature'); resolverTrees = await resolver.createTrees(treeOptions); // we only requested a single alert so there's only 1 tree tree = resolverTrees.trees[0]; }); after(async () => { await resolver.deleteData(resolverTrees); - // this unload is for an endgame-* index so it does not use data streams - await esArchiver.unload('endpoint/resolver/api_feature'); }); - describe('ancestry events route', () => { - describe('legacy events', () => { - const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; - const entityID = '94042'; - - it('should return details for the root node', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get( - `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=5` - ) - .expect(200); - expect(body.ancestors[0].lifecycle.length).to.eql(2); - expect(body.ancestors.length).to.eql(2); - expect(body.nextAncestor).to.eql(null); - }); - - it('should have a populated next parameter', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get( - `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` - ) - .expect(200); - expect(body.nextAncestor).to.eql('94041'); + describe('ancestry events', () => { + it('should return the correct ancestor nodes for the tree', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 9, + schema: schemaWithAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [{ origin: tree.origin.id, nodeExpectations: { ancestors: 5 } }], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); + }); - it('should handle an ancestors param request', async () => { - let { body }: { body: SafeResolverAncestry } = await supertest - .get( - `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` - ) - .expect(200); - const next = body.nextAncestor; + it('should handle an invalid id', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 9, + schema: schemaWithAncestry, + nodes: ['bogus id'], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + expect(body).to.be.empty(); + }); - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${next}/ancestry?legacyEndpointID=${endpointID}&ancestors=1` - ) - .expect(200)); - expect(body.ancestors[0].lifecycle.length).to.eql(1); - expect(body.nextAncestor).to.eql(null); + it('should return a subset of the ancestors', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + // 3 ancestors means 1 origin and 2 ancestors of the origin + ancestors: 3, + schema: schemaWithAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [{ origin: tree.origin.id, nodeExpectations: { ancestors: 2 } }], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); }); - describe('endpoint events', () => { - it('should return the origin node at the front of the array', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=9`) - .expect(200); - expect(body.ancestors[0].entityID).to.eql(tree.origin.id); + it('should return ancestors without the ancestry array', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [{ origin: tree.origin.id, nodeExpectations: { ancestors: 5 } }], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('should return details for the root node', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=9`) - .expect(200); - // the tree we generated had 5 ancestors + 1 origin node - expect(body.ancestors.length).to.eql(6); - expect(body.ancestors[0].entityID).to.eql(tree.origin.id); - verifyAncestry(body.ancestors, tree, true); - expect(body.nextAncestor).to.eql(null); + it('should respect the time range specified and only return the origin node', async () => { + const from = new Date( + timestampSafeVersion(tree.origin.lifecycle[0]) ?? new Date() + ).toISOString(); + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from, + to: from, + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [{ origin: tree.origin.id, nodeExpectations: { ancestors: 0 } }], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('should handle an invalid id', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/alskdjflasj/ancestry`) - .expect(200); - expect(body.ancestors).to.be.empty(); - expect(body.nextAncestor).to.eql(null); + it('should support returning multiple ancestor trees when multiple nodes are requested', async () => { + // There should be 2 levels of descendants under the origin, grab the bottom one, and the first node's id + const bottomMostDescendant = Array.from(tree.childrenLevels[1].values())[0].id; + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id, bottomMostDescendant], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 5 ancestors above the origin + { origin: tree.origin.id, nodeExpectations: { ancestors: 5 } }, + // there are 2 levels below the origin so the bottom node's ancestry should be + // all the ancestors (5) + one level + the origin = 7 + { origin: bottomMostDescendant, nodeExpectations: { ancestors: 7 } }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('should have a populated next parameter', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=2`) - .expect(200); - // it should have 2 ancestors + 1 origin - expect(body.ancestors.length).to.eql(3); - verifyAncestry(body.ancestors, tree, false); - const distantGrandparent = retrieveDistantAncestor(body.ancestors); - expect(body.nextAncestor).to.eql( - parentEntityIDSafeVersion(distantGrandparent.lifecycle[0]) - ); + it('should return a single ancestry when two nodes a the same level and from same parent are requested', async () => { + // there are 2 levels after the origin, let's get the first level, there will be three + // children so get the left and right most ones + const level0Nodes = Array.from(tree.childrenLevels[0].values()); + const leftNode = level0Nodes[0].id; + const rightNode = level0Nodes[2].id; + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [leftNode, rightNode], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // We should be 1 level below the origin so the node's ancestry should be + // all the ancestors (5) + the origin = 6 + { origin: leftNode, nodeExpectations: { ancestors: 6 } }, + // these nodes should be at the same level so the ancestors should be the same number + { origin: rightNode, nodeExpectations: { ancestors: 6 } }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('should handle multiple ancestor requests', async () => { - let { body }: { body: SafeResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=3`) - .expect(200); - expect(body.ancestors.length).to.eql(4); - const next = body.nextAncestor; - - ({ body } = await supertest - .get(`/api/endpoint/resolver/${next}/ancestry?ancestors=1`) - .expect(200)); - expect(body.ancestors.length).to.eql(2); - verifyAncestry(body.ancestors, tree, true); - // the highest node in the generated tree will not have a parent ID which causes the server to return - // without setting the pagination so nextAncestor will be null - expect(body.nextAncestor).to.eql(null); - }); + it('should not return any nodes when the search index does not have any data', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['metrics-*'], + }) + .expect(200); + expect(body).to.be.empty(); }); }); - describe('children route', () => { - describe('legacy events', () => { - const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; - const entityID = '94041'; - - it('returns child process lifecycle events', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${entityID}/children?legacyEndpointID=${endpointID}`) - .expect(200); - expect(body.childNodes.length).to.eql(1); - expect(body.childNodes[0].lifecycle.length).to.eql(2); - expect( - // for some reason the ts server doesn't think `endgame` exists even though we're using ResolverEvent - // here, so to avoid it complaining we'll just force it - (body.childNodes[0].lifecycle[0] as SafeLegacyEndpointEvent).endgame.unique_pid - ).to.eql(94042); + describe('descendant events', () => { + it('returns all descendants for the origin without using the ancestry field', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 2, + ancestors: 0, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 2 levels in the descendant part of the tree and 3 nodes for each + // descendant = 3 children for the origin + 3 children for each of the origin's children = 12 + { origin: tree.origin.id, nodeExpectations: { descendants: 12, descendantLevels: 2 } }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('returns multiple levels of child process lifecycle events', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/93802/children?legacyEndpointID=${endpointID}&children=10`) - .expect(200); - expect(body.childNodes.length).to.eql(10); - expect(body.nextChild).to.be(null); - expect(body.childNodes[0].lifecycle.length).to.eql(1); - expect( - // for some reason the ts server doesn't think `endgame` exists even though we're using ResolverEvent - // here, so to avoid it complaining we'll just force it - (body.childNodes[0].lifecycle[0] as SafeLegacyEndpointEvent).endgame.unique_pid - ).to.eql(93932); + it('returns all descendants for the origin using the ancestry field', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + // should be ignored when using the ancestry array + descendantLevels: 0, + ancestors: 0, + schema: schemaWithAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 2 levels in the descendant part of the tree and 3 nodes for each + // descendant = 3 children for the origin + 3 children for each of the origin's children = 12 + { origin: tree.origin.id, nodeExpectations: { descendants: 12, descendantLevels: 2 } }, + ], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); + }); - it('returns no values when there is no more data', async () => { - let { body }: { body: SafeResolverChildren } = await supertest - .get( - // there should only be a single child for this node - `/api/endpoint/resolver/94041/children?legacyEndpointID=${endpointID}&children=1` - ) - .expect(200); - expect(body.nextChild).to.not.be(null); - - ({ body } = await supertest - .get( - `/api/endpoint/resolver/94041/children?legacyEndpointID=${endpointID}&afterChild=${body.nextChild}` - ) - .expect(200)); - expect(body.childNodes).be.empty(); - expect(body.nextChild).to.eql(null); - }); + it('should handle an invalid id', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 100, + ancestors: 0, + schema: schemaWithAncestry, + nodes: ['bogus id'], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + expect(body).to.be.empty(); + }); - it('returns the first page of information when the cursor is invalid', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get( - `/api/endpoint/resolver/${entityID}/children?legacyEndpointID=${endpointID}&afterChild=blah` - ) - .expect(200); - expect(body.childNodes.length).to.eql(1); - expect(body.nextChild).to.be(null); + it('returns a single generation of children', async () => { + // this gets a node should have 3 children which were created in succession so that the timestamps + // are ordered correctly to be retrieved in a single call + const childID = Array.from(tree.childrenLevels[0].values())[0].id; + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 1, + ancestors: 0, + schema: schemaWithoutAncestry, + nodes: [childID], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // a single generation should be three nodes + { origin: childID, nodeExpectations: { descendants: 3, descendantLevels: 1 } }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('errors on invalid pagination values', async () => { - await supertest.get(`/api/endpoint/resolver/${entityID}/children?children=0`).expect(400); - await supertest - .get(`/api/endpoint/resolver/${entityID}/children?children=20000`) - .expect(400); - await supertest - .get(`/api/endpoint/resolver/${entityID}/children?children=-1`) - .expect(400); + it('should support returning multiple descendant trees when multiple nodes are requested', async () => { + // there are 2 levels after the origin, let's get the first level, there will be three + // children so get the left and right most ones + const level0Nodes = Array.from(tree.childrenLevels[0].values()); + const leftNodeID = level0Nodes[0].id; + const rightNodeID = level0Nodes[2].id; + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 6, + descendantLevels: 0, + ancestors: 0, + schema: schemaWithAncestry, + nodes: [leftNodeID, rightNodeID], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + { origin: leftNodeID, nodeExpectations: { descendantLevels: 1, descendants: 3 } }, + { origin: rightNodeID, nodeExpectations: { descendantLevels: 1, descendants: 3 } }, + ], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); + }); - it('returns empty events without a matching entity id', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/5555/children`) - .expect(200); - expect(body.nextChild).to.eql(null); - expect(body.childNodes).to.be.empty(); + it('should support returning multiple descendant trees when multiple nodes are requested at different levels', async () => { + const originParent = parentEntityIDSafeVersion(tree.origin.lifecycle[0]) ?? ''; + expect(originParent).to.not.be(''); + const originGrandparent = + parentEntityIDSafeVersion(tree.ancestry.get(originParent)!.lifecycle[0]) ?? ''; + expect(originGrandparent).to.not.be(''); + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 2, + descendantLevels: 0, + ancestors: 0, + schema: schemaWithAncestry, + nodes: [tree.origin.id, originGrandparent], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + { origin: tree.origin.id, nodeExpectations: { descendantLevels: 1, descendants: 1 } }, + // the origin's grandparent should only have the origin's parent as a descendant + { + origin: originGrandparent, + nodeExpectations: { descendantLevels: 1, descendants: 1 }, + }, + ], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); + }); - it('returns empty events with an invalid endpoint id', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${entityID}/children?legacyEndpointID=foo`) - .expect(200); - expect(body.nextChild).to.eql(null); - expect(body.childNodes).to.be.empty(); + it('should support returning multiple descendant trees when multiple nodes are requested at different levels without ancestry field', async () => { + const originParent = parentEntityIDSafeVersion(tree.origin.lifecycle[0]) ?? ''; + expect(originParent).to.not.be(''); + const originGrandparent = + parentEntityIDSafeVersion(tree.ancestry.get(originParent)!.lifecycle[0]) ?? ''; + expect(originGrandparent).to.not.be(''); + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 6, + descendantLevels: 1, + ancestors: 0, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id, originGrandparent], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + { origin: tree.origin.id, nodeExpectations: { descendantLevels: 1, descendants: 3 } }, + // the origin's grandparent should only have the origin's parent as a descendant + { + origin: originGrandparent, + nodeExpectations: { descendantLevels: 1, descendants: 1 }, + }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); }); - describe('endpoint events', () => { - it('returns all children for the origin', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/children?children=100`) - .expect(200); - // there are 2 levels in the children part of the tree and 3 nodes for each = - // 3 children for the origin + 3 children for each of the origin's children = 12 - expect(body.childNodes.length).to.eql(12); - // there will be 4 parents, the origin of the tree, and it's 3 children - verifyChildren(body.childNodes, tree, 4, 3); - expect(body.nextChild).to.eql(null); + it('should respect the time range specified and only return one descendant', async () => { + const level0Node = Array.from(tree.childrenLevels[0].values())[0]; + const end = new Date( + timestampSafeVersion(level0Node.lifecycle[0]) ?? new Date() + ).toISOString(); + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 5, + ancestors: 0, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: end, + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + { origin: tree.origin.id, nodeExpectations: { descendantLevels: 1, descendants: 1 } }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); + }); - it('returns a single generation of children', async () => { - // this gets a node should have 3 children which were created in succession so that the timestamps - // are ordered correctly to be retrieved in a single call - const distantChildEntityID = Array.from(tree.childrenLevels[0].values())[0].id; - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${distantChildEntityID}/children?children=3`) - .expect(200); - expect(body.childNodes.length).to.eql(3); - verifyChildren(body.childNodes, tree, 1, 3); - expect(body.nextChild).to.not.eql(null); + describe('ancestry and descendants', () => { + it('returns all descendants and ancestors without the ancestry field and they should have the name field', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 10, + ancestors: 50, + schema: schemaWithName, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 2 levels in the descendant part of the tree and 3 nodes for each + // descendant = 3 children for the origin + 3 children for each of the origin's children = 12 + { + origin: tree.origin.id, + nodeExpectations: { descendants: 12, descendantLevels: 2, ancestors: 5 }, + }, + ], + response: body, + schema: schemaWithName, + genTree: tree, + relatedEventsCategories: relatedEventsToGen, }); - it('paginates the children', async () => { - // this gets a node should have 3 children which were created in succession so that the timestamps - // are ordered correctly to be retrieved in a single call - const distantChildEntityID = Array.from(tree.childrenLevels[0].values())[0].id; - let { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${distantChildEntityID}/children?children=1`) - .expect(200); - expect(body.childNodes.length).to.eql(1); - verifyChildren(body.childNodes, tree, 1, 1); - expect(body.nextChild).to.not.be(null); - - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${distantChildEntityID}/children?children=2&afterChild=${body.nextChild}` - ) - .expect(200)); - expect(body.childNodes.length).to.eql(2); - verifyChildren(body.childNodes, tree, 1, 2); - expect(body.nextChild).to.not.be(null); + for (const node of body) { + expect(node.name).to.be(getNameField(node.data, schemaWithName)); + expect(node.name).to.not.be(undefined); + } + }); - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${distantChildEntityID}/children?children=2&afterChild=${body.nextChild}` - ) - .expect(200)); - expect(body.childNodes.length).to.eql(0); - expect(body.nextChild).to.be(null); + it('returns all descendants and ancestors without the ancestry field', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 10, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 2 levels in the descendant part of the tree and 3 nodes for each + // descendant = 3 children for the origin + 3 children for each of the origin's children = 12 + { + origin: tree.origin.id, + nodeExpectations: { descendants: 12, descendantLevels: 2, ancestors: 5 }, + }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, + relatedEventsCategories: relatedEventsToGen, }); - it('gets all children in two queries', async () => { - // should get all the children of the origin - let { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/children?children=3`) - .expect(200); - expect(body.childNodes.length).to.eql(3); - verifyChildren(body.childNodes, tree); - expect(body.nextChild).to.not.be(null); - const firstNodes = [...body.childNodes]; - - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${tree.origin.id}/children?children=10&afterChild=${body.nextChild}` - ) - .expect(200)); - expect(body.childNodes.length).to.eql(9); - // put all the results together and we should have all the children - verifyChildren([...firstNodes, ...body.childNodes], tree, 4, 3); - expect(body.nextChild).to.be(null); - }); + for (const node of body) { + expect(node.name).to.be(getNameField(node.data, schemaWithoutAncestry)); + expect(node.name).to.be(undefined); + } }); - }); - - describe('tree api', () => { - describe('legacy events', () => { - const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; - it('returns ancestors, events, children, and current process lifecycle', async () => { - const { body }: { body: SafeResolverTree } = await supertest - .get(`/api/endpoint/resolver/93933?legacyEndpointID=${endpointID}`) - .expect(200); - expect(body.ancestry.nextAncestor).to.equal(null); - expect(body.children.nextChild).to.equal(null); - expect(body.children.childNodes.length).to.equal(0); - expect(body.lifecycle.length).to.equal(2); + it('returns all descendants and ancestors with the ancestry field', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 10, + ancestors: 50, + schema: schemaWithAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 2 levels in the descendant part of the tree and 3 nodes for each + // descendant = 3 children for the origin + 3 children for each of the origin's children = 12 + { + origin: tree.origin.id, + nodeExpectations: { descendants: 12, descendantLevels: 2, ancestors: 5 }, + }, + ], + response: body, + schema: schemaWithAncestry, + genTree: tree, + relatedEventsCategories: relatedEventsToGen, }); - }); - - describe('endpoint events', () => { - it('returns a tree', async () => { - const { body }: { body: SafeResolverTree } = await supertest - .get( - `/api/endpoint/resolver/${tree.origin.id}?children=100&ancestors=5&events=5&alerts=5` - ) - .expect(200); - - expect(body.children.nextChild).to.equal(null); - expect(body.children.childNodes.length).to.equal(12); - verifyChildren(body.children.childNodes, tree, 4, 3); - verifyLifecycleStats(body.children.childNodes, relatedEventsToGen, relatedAlerts); - expect(body.ancestry.nextAncestor).to.equal(null); - verifyAncestry(body.ancestry.ancestors, tree, true); - verifyLifecycleStats(body.ancestry.ancestors, relatedEventsToGen, relatedAlerts); - - expect(body.relatedAlerts.nextAlert).to.equal(null); - compareArrays(tree.origin.relatedAlerts, body.relatedAlerts.alerts, true); + for (const node of body) { + expect(node.name).to.be(getNameField(node.data, schemaWithAncestry)); + expect(node.name).to.be(undefined); + } + }); - compareArrays(tree.origin.lifecycle, body.lifecycle, true); - verifyStats(body.stats, relatedEventsToGen, relatedAlerts); + it('returns an empty response when limits are zero', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 0, + schema: schemaWithAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + expect(body).to.be.empty(); + verifyTree({ + expectations: [ + { + origin: tree.origin.id, + nodeExpectations: { descendants: 0, descendantLevels: 0, ancestors: 0 }, + }, + ], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); }); }); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree_entity_id.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree_entity_id.ts new file mode 100644 index 00000000000000..39cce77b8cc9d4 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree_entity_id.ts @@ -0,0 +1,375 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { + SafeResolverAncestry, + SafeResolverChildren, + SafeResolverTree, + SafeLegacyEndpointEvent, +} from '../../../../plugins/security_solution/common/endpoint/types'; +import { parentEntityIDSafeVersion } from '../../../../plugins/security_solution/common/endpoint/models/event'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { + Tree, + RelatedEventCategory, +} from '../../../../plugins/security_solution/common/endpoint/generate_data'; +import { Options, GeneratedTrees } from '../../services/resolver'; +import { + compareArrays, + checkAncestryFromEntityTreeAPI, + retrieveDistantAncestor, + verifyChildrenFromEntityTreeAPI, + verifyLifecycleStats, + verifyEntityTreeStats, +} from './common'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const resolver = getService('resolverGenerator'); + + const relatedEventsToGen = [ + { category: RelatedEventCategory.Driver, count: 2 }, + { category: RelatedEventCategory.File, count: 1 }, + { category: RelatedEventCategory.Registry, count: 1 }, + ]; + const relatedAlerts = 4; + let resolverTrees: GeneratedTrees; + let tree: Tree; + const treeOptions: Options = { + ancestors: 5, + relatedEvents: relatedEventsToGen, + relatedAlerts, + children: 3, + generations: 2, + percentTerminated: 100, + percentWithRelated: 100, + numTrees: 1, + alwaysGenMaxChildrenPerNode: true, + ancestryArraySize: 2, + }; + + describe('Resolver entity tree api', () => { + before(async () => { + await esArchiver.load('endpoint/resolver/api_feature'); + resolverTrees = await resolver.createTrees(treeOptions); + // we only requested a single alert so there's only 1 tree + tree = resolverTrees.trees[0]; + }); + after(async () => { + await resolver.deleteData(resolverTrees); + // this unload is for an endgame-* index so it does not use data streams + await esArchiver.unload('endpoint/resolver/api_feature'); + }); + + describe('ancestry events route', () => { + describe('legacy events', () => { + const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; + const entityID = '94042'; + + it('should return details for the root node', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get( + `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=5` + ) + .expect(200); + expect(body.ancestors[0].lifecycle.length).to.eql(2); + expect(body.ancestors.length).to.eql(2); + expect(body.nextAncestor).to.eql(null); + }); + + it('should have a populated next parameter', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get( + `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` + ) + .expect(200); + expect(body.nextAncestor).to.eql('94041'); + }); + + it('should handle an ancestors param request', async () => { + let { body }: { body: SafeResolverAncestry } = await supertest + .get( + `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` + ) + .expect(200); + const next = body.nextAncestor; + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/${next}/ancestry?legacyEndpointID=${endpointID}&ancestors=1` + ) + .expect(200)); + expect(body.ancestors[0].lifecycle.length).to.eql(1); + expect(body.nextAncestor).to.eql(null); + }); + }); + + describe('endpoint events', () => { + it('should return the origin node at the front of the array', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=9`) + .expect(200); + expect(body.ancestors[0].entityID).to.eql(tree.origin.id); + }); + + it('should return details for the root node', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=9`) + .expect(200); + // the tree we generated had 5 ancestors + 1 origin node + expect(body.ancestors.length).to.eql(6); + expect(body.ancestors[0].entityID).to.eql(tree.origin.id); + checkAncestryFromEntityTreeAPI(body.ancestors, tree, true); + expect(body.nextAncestor).to.eql(null); + }); + + it('should handle an invalid id', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get(`/api/endpoint/resolver/alskdjflasj/ancestry`) + .expect(200); + expect(body.ancestors).to.be.empty(); + expect(body.nextAncestor).to.eql(null); + }); + + it('should have a populated next parameter', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=2`) + .expect(200); + // it should have 2 ancestors + 1 origin + expect(body.ancestors.length).to.eql(3); + checkAncestryFromEntityTreeAPI(body.ancestors, tree, false); + const distantGrandparent = retrieveDistantAncestor(body.ancestors); + expect(body.nextAncestor).to.eql( + parentEntityIDSafeVersion(distantGrandparent.lifecycle[0]) + ); + }); + + it('should handle multiple ancestor requests', async () => { + let { body }: { body: SafeResolverAncestry } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=3`) + .expect(200); + expect(body.ancestors.length).to.eql(4); + const next = body.nextAncestor; + + ({ body } = await supertest + .get(`/api/endpoint/resolver/${next}/ancestry?ancestors=1`) + .expect(200)); + expect(body.ancestors.length).to.eql(2); + checkAncestryFromEntityTreeAPI(body.ancestors, tree, true); + // the highest node in the generated tree will not have a parent ID which causes the server to return + // without setting the pagination so nextAncestor will be null + expect(body.nextAncestor).to.eql(null); + }); + }); + }); + + describe('children route', () => { + describe('legacy events', () => { + const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; + const entityID = '94041'; + + it('returns child process lifecycle events', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${entityID}/children?legacyEndpointID=${endpointID}`) + .expect(200); + expect(body.childNodes.length).to.eql(1); + expect(body.childNodes[0].lifecycle.length).to.eql(2); + expect( + // for some reason the ts server doesn't think `endgame` exists even though we're using ResolverEvent + // here, so to avoid it complaining we'll just force it + (body.childNodes[0].lifecycle[0] as SafeLegacyEndpointEvent).endgame.unique_pid + ).to.eql(94042); + }); + + it('returns multiple levels of child process lifecycle events', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/93802/children?legacyEndpointID=${endpointID}&children=10`) + .expect(200); + expect(body.childNodes.length).to.eql(10); + expect(body.nextChild).to.be(null); + expect(body.childNodes[0].lifecycle.length).to.eql(1); + expect( + // for some reason the ts server doesn't think `endgame` exists even though we're using ResolverEvent + // here, so to avoid it complaining we'll just force it + (body.childNodes[0].lifecycle[0] as SafeLegacyEndpointEvent).endgame.unique_pid + ).to.eql(93932); + }); + + it('returns no values when there is no more data', async () => { + let { body }: { body: SafeResolverChildren } = await supertest + .get( + // there should only be a single child for this node + `/api/endpoint/resolver/94041/children?legacyEndpointID=${endpointID}&children=1` + ) + .expect(200); + expect(body.nextChild).to.not.be(null); + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/94041/children?legacyEndpointID=${endpointID}&afterChild=${body.nextChild}` + ) + .expect(200)); + expect(body.childNodes).be.empty(); + expect(body.nextChild).to.eql(null); + }); + + it('returns the first page of information when the cursor is invalid', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get( + `/api/endpoint/resolver/${entityID}/children?legacyEndpointID=${endpointID}&afterChild=blah` + ) + .expect(200); + expect(body.childNodes.length).to.eql(1); + expect(body.nextChild).to.be(null); + }); + + it('errors on invalid pagination values', async () => { + await supertest.get(`/api/endpoint/resolver/${entityID}/children?children=0`).expect(400); + await supertest + .get(`/api/endpoint/resolver/${entityID}/children?children=20000`) + .expect(400); + await supertest + .get(`/api/endpoint/resolver/${entityID}/children?children=-1`) + .expect(400); + }); + + it('returns empty events without a matching entity id', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/5555/children`) + .expect(200); + expect(body.nextChild).to.eql(null); + expect(body.childNodes).to.be.empty(); + }); + + it('returns empty events with an invalid endpoint id', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${entityID}/children?legacyEndpointID=foo`) + .expect(200); + expect(body.nextChild).to.eql(null); + expect(body.childNodes).to.be.empty(); + }); + }); + + describe('endpoint events', () => { + it('returns all children for the origin', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/children?children=100`) + .expect(200); + // there are 2 levels in the children part of the tree and 3 nodes for each = + // 3 children for the origin + 3 children for each of the origin's children = 12 + expect(body.childNodes.length).to.eql(12); + // there will be 4 parents, the origin of the tree, and it's 3 children + verifyChildrenFromEntityTreeAPI(body.childNodes, tree, 4, 3); + expect(body.nextChild).to.eql(null); + }); + + it('returns a single generation of children', async () => { + // this gets a node should have 3 children which were created in succession so that the timestamps + // are ordered correctly to be retrieved in a single call + const distantChildEntityID = Array.from(tree.childrenLevels[0].values())[0].id; + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${distantChildEntityID}/children?children=3`) + .expect(200); + expect(body.childNodes.length).to.eql(3); + verifyChildrenFromEntityTreeAPI(body.childNodes, tree, 1, 3); + expect(body.nextChild).to.not.eql(null); + }); + + it('paginates the children', async () => { + // this gets a node should have 3 children which were created in succession so that the timestamps + // are ordered correctly to be retrieved in a single call + const distantChildEntityID = Array.from(tree.childrenLevels[0].values())[0].id; + let { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${distantChildEntityID}/children?children=1`) + .expect(200); + expect(body.childNodes.length).to.eql(1); + verifyChildrenFromEntityTreeAPI(body.childNodes, tree, 1, 1); + expect(body.nextChild).to.not.be(null); + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/${distantChildEntityID}/children?children=2&afterChild=${body.nextChild}` + ) + .expect(200)); + expect(body.childNodes.length).to.eql(2); + verifyChildrenFromEntityTreeAPI(body.childNodes, tree, 1, 2); + expect(body.nextChild).to.not.be(null); + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/${distantChildEntityID}/children?children=2&afterChild=${body.nextChild}` + ) + .expect(200)); + expect(body.childNodes.length).to.eql(0); + expect(body.nextChild).to.be(null); + }); + + it('gets all children in two queries', async () => { + // should get all the children of the origin + let { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/children?children=3`) + .expect(200); + expect(body.childNodes.length).to.eql(3); + verifyChildrenFromEntityTreeAPI(body.childNodes, tree); + expect(body.nextChild).to.not.be(null); + const firstNodes = [...body.childNodes]; + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/${tree.origin.id}/children?children=10&afterChild=${body.nextChild}` + ) + .expect(200)); + expect(body.childNodes.length).to.eql(9); + // put all the results together and we should have all the children + verifyChildrenFromEntityTreeAPI([...firstNodes, ...body.childNodes], tree, 4, 3); + expect(body.nextChild).to.be(null); + }); + }); + }); + + describe('tree api', () => { + describe('legacy events', () => { + const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; + + it('returns ancestors, events, children, and current process lifecycle', async () => { + const { body }: { body: SafeResolverTree } = await supertest + .get(`/api/endpoint/resolver/93933?legacyEndpointID=${endpointID}`) + .expect(200); + expect(body.ancestry.nextAncestor).to.equal(null); + expect(body.children.nextChild).to.equal(null); + expect(body.children.childNodes.length).to.equal(0); + expect(body.lifecycle.length).to.equal(2); + }); + }); + + describe('endpoint events', () => { + it('returns a tree', async () => { + const { body }: { body: SafeResolverTree } = await supertest + .get( + `/api/endpoint/resolver/${tree.origin.id}?children=100&ancestors=5&events=5&alerts=5` + ) + .expect(200); + + expect(body.children.nextChild).to.equal(null); + expect(body.children.childNodes.length).to.equal(12); + verifyChildrenFromEntityTreeAPI(body.children.childNodes, tree, 4, 3); + verifyLifecycleStats(body.children.childNodes, relatedEventsToGen, relatedAlerts); + + expect(body.ancestry.nextAncestor).to.equal(null); + checkAncestryFromEntityTreeAPI(body.ancestry.ancestors, tree, true); + verifyLifecycleStats(body.ancestry.ancestors, relatedEventsToGen, relatedAlerts); + + expect(body.relatedAlerts.nextAlert).to.equal(null); + compareArrays(tree.origin.relatedAlerts, body.relatedAlerts.alerts, true); + + compareArrays(tree.origin.lifecycle, body.lifecycle, true); + verifyEntityTreeStats(body.stats, relatedEventsToGen, relatedAlerts); + }); + }); + }); + }); +} diff --git a/yarn.lock b/yarn.lock index 355832d14e6011..8d47d3e8437883 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1416,10 +1416,10 @@ pump "^3.0.0" secure-json-parse "^2.1.0" -"@elastic/ems-client@7.10.0": - version "7.10.0" - resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-7.10.0.tgz#6d0e12ce99acd122d8066aa0a8685ecfd21637d3" - integrity sha512-84XqAhY4iaKwo2PnDwskNLvnprR3EYcS1AhN048xa8mIZlRJuycB4DwWnB699qvUTQqKcg5qLS0o5sEUs2HDeA== +"@elastic/ems-client@7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-7.11.0.tgz#d2142d0ef5bd1aff7ae67b37c1394b73cdd48d8b" + integrity sha512-7+gDEkBr8nRS7X9i/UPg1WkS7bEBuNbBBjXCchQeYwqPRmw6vOb4wjlNzVwmOFsp2OH4lVFfZ+XU4pxTt32EXA== dependencies: lodash "^4.17.15" semver "7.3.2" @@ -14255,10 +14255,10 @@ gaze@^1.0.0, gaze@^1.1.0: dependencies: globule "^1.0.0" -geckodriver@^1.20.0: - version "1.20.0" - resolved "https://registry.yarnpkg.com/geckodriver/-/geckodriver-1.20.0.tgz#cd16edb177b88e31affcb54b18a238cae88950a7" - integrity sha512-5nVF4ixR+ZGhVsc4udnVihA9RmSlO6guPV1d2HqxYsgAOUNh0HfzxbzG7E49w4ilXq/CSu87x9yWvrsOstrADQ== +geckodriver@^1.21.0: + version "1.21.0" + resolved "https://registry.yarnpkg.com/geckodriver/-/geckodriver-1.21.0.tgz#1f04780ebfb451ffd08fa8fddc25cc26e37ac4a2" + integrity sha512-NamdJwGIWpPiafKQIvGman95BBi/SBqHddRXAnIEpFNFCFToTW0sEA0nUckMKCBNn1DVIcLfULfyFq/sTn9bkA== dependencies: adm-zip "0.4.16" bluebird "3.7.2" @@ -27649,10 +27649,10 @@ typescript-tuple@^2.2.1: dependencies: typescript-compare "^0.0.2" -typescript@4.0.2, typescript@^3.0.3, typescript@^3.2.2, typescript@^3.3.3333, typescript@^3.4.5, typescript@~3.7.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2" - integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ== +typescript@4.1.2, typescript@^3.0.3, typescript@^3.2.2, typescript@^3.3.3333, typescript@^3.4.5, typescript@~3.7.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.2.tgz#6369ef22516fe5e10304aae5a5c4862db55380e9" + integrity sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ== ua-parser-js@^0.7.18: version "0.7.22"