diff --git a/.ci/build_docker.sh b/.ci/build_docker.sh index 1f45182aad840..07013f13cdae5 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/.ci/es-snapshots/Jenkinsfile_verify_es b/.ci/es-snapshots/Jenkinsfile_verify_es index a6fe980242afe..3c38d6279a038 100644 --- a/.ci/es-snapshots/Jenkinsfile_verify_es +++ b/.ci/es-snapshots/Jenkinsfile_verify_es @@ -55,6 +55,7 @@ kibanaPipeline(timeoutMinutes: 150) { 'xpack-ciGroup8': kibanaPipeline.xpackCiGroupProcess(8), 'xpack-ciGroup9': kibanaPipeline.xpackCiGroupProcess(9), 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), + 'xpack-ciGroup11': kibanaPipeline.xpackCiGroupProcess(11), ]), ]) } diff --git a/.ci/jobs.yml b/.ci/jobs.yml index 3add92aadd256..d4ec8a3d5a699 100644 --- a/.ci/jobs.yml +++ b/.ci/jobs.yml @@ -31,6 +31,7 @@ JOB: - x-pack-ciGroup8 - x-pack-ciGroup9 - x-pack-ciGroup10 + - x-pack-ciGroup11 - x-pack-accessibility - x-pack-visualRegression diff --git a/.ci/packer_cache_for_branch.sh b/.ci/packer_cache_for_branch.sh index 0d9b22b04dbd0..bc427bf927f11 100755 --- a/.ci/packer_cache_for_branch.sh +++ b/.ci/packer_cache_for_branch.sh @@ -49,7 +49,8 @@ tar -cf "$HOME/.kibana/bootstrap_cache/$branch.tar" \ .chromium \ .es \ .chromedriver \ - .geckodriver; + .geckodriver \ + .yarn-local-mirror; echo "created $HOME/.kibana/bootstrap_cache/$branch.tar" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index be2b4533e22db..bb4c500283020 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 @@ -141,9 +142,8 @@ #CC# /src/plugins/maps_oss/ @elastic/kibana-gis #CC# /x-pack/plugins/file_upload @elastic/kibana-gis #CC# /x-pack/plugins/maps_legacy_licensing @elastic/kibana-gis -#CC# /src/plugins/home/server/tutorials @elastic/kibana-gis -#CC# /src/plugins/tile_map/ @elastic/kibana-gis -#CC# /src/plugins/region_map/ @elastic/kibana-gis +/src/plugins/tile_map/ @elastic/kibana-gis +/src/plugins/region_map/ @elastic/kibana-gis # Operations /src/dev/ @elastic/kibana-operations @@ -204,6 +204,28 @@ #CC# /x-pack/plugins/features/ @elastic/kibana-core #CC# /x-pack/plugins/global_search/ @elastic/kibana-core +# Kibana Telemetry +/packages/kbn-analytics/ @elastic/kibana-core +/packages/kbn-telemetry-tools/ @elastic/kibana-core +/src/plugins/kibana_usage_collection/ @elastic/kibana-core +/src/plugins/newsfeed/ @elastic/kibana-core +/src/plugins/telemetry/ @elastic/kibana-core +/src/plugins/telemetry_collection_manager/ @elastic/kibana-core +/src/plugins/telemetry_management_section/ @elastic/kibana-core +/src/plugins/usage_collection/ @elastic/kibana-core +/x-pack/plugins/telemetry_collection_xpack/ @elastic/kibana-core +/.telemetryrc.json @elastic/kibana-core +/x-pack/.telemetryrc.json @elastic/kibana-core +src/plugins/telemetry/schema/legacy_oss_plugins.json @elastic/kibana-core +src/plugins/telemetry/schema/oss_plugins.json @elastic/kibana-core +x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kibana-core + +# Kibana Localization +/src/dev/i18n/ @elastic/kibana-localization @elastic/kibana-core +/src/core/public/i18n/ @elastic/kibana-localization @elastic/kibana-core +/packages/kbn-i18n/ @elastic/kibana-localization @elastic/kibana-core +#CC# /x-pack/plugins/translations/ @elastic/kibana-localization @elastic/kibana-core + # Security /src/core/server/csp/ @elastic/kibana-security @elastic/kibana-core /src/plugins/security_oss/ @elastic/kibana-security @@ -221,28 +243,6 @@ #CC# /x-pack/plugins/security_solution/ @elastic/kibana-security #CC# /x-pack/plugins/security/ @elastic/kibana-security -# Kibana Localization -/src/dev/i18n/ @elastic/kibana-localization -/src/core/public/i18n/ @elastic/kibana-localization -/packages/kbn-i18n/ @elastic/kibana-localization -#CC# /x-pack/plugins/translations/ @elastic/kibana-localization - -# Kibana Telemetry -/packages/kbn-analytics/ @elastic/kibana-telemetry -/packages/kbn-telemetry-tools/ @elastic/kibana-telemetry -/src/plugins/kibana_usage_collection/ @elastic/kibana-telemetry -/src/plugins/newsfeed/ @elastic/kibana-telemetry -/src/plugins/telemetry/ @elastic/kibana-telemetry -/src/plugins/telemetry_collection_manager/ @elastic/kibana-telemetry -/src/plugins/telemetry_management_section/ @elastic/kibana-telemetry -/src/plugins/usage_collection/ @elastic/kibana-telemetry -/x-pack/plugins/telemetry_collection_xpack/ @elastic/kibana-telemetry -/.telemetryrc.json @elastic/kibana-telemetry -/x-pack/.telemetryrc.json @elastic/kibana-telemetry -src/plugins/telemetry/schema/legacy_oss_plugins.json @elastic/kibana-telemetry -src/plugins/telemetry/schema/oss_plugins.json @elastic/kibana-telemetry -x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kibana-telemetry - # Kibana Alerting Services /x-pack/plugins/alerts/ @elastic/kibana-alerting-services /x-pack/plugins/actions/ @elastic/kibana-alerting-services diff --git a/.gitignore b/.gitignore index 45034583cffbb..b786a419383b9 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ report.asciidoc # TS incremental build cache *.tsbuildinfo + +# Yarn local mirror content +.yarn-local-mirror diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000000000..eceec9ca34a22 --- /dev/null +++ b/.yarnrc @@ -0,0 +1,5 @@ +# Configure an offline yarn mirror in the data folder +yarn-offline-mirror ".yarn-local-mirror" + +# Always look into the cache first before fetching online +--install.prefer-offline true diff --git a/README.md b/README.md index b786d95ce2994..6977c198e904d 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 3891b87a00e64..8b6f7d5448364 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 0000000000000..48595690f9784 --- /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 0d31f5d90f668..047c3dffa6358 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 dc15b90b69d1a..7fa7d80ef9729 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 0000000000000..2005a90bb87bb --- /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 42b379e606898..b048e59e6c98c 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 0000000000000..70f27005db372 --- /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 485b7af6a6221..9c54ef9c8a916 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 1fe211c87c660..863a67f3c42f0 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 dd83cf234dea4..c74e4c91ef278 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 0000000000000..469f7a4f3adb1 --- /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 '@hapi/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 0000000000000..337d02b11ee91 --- /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 0000000000000..0b33a585863a4 --- /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 0000000000000..6e856d2e2578a --- /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 0000000000000..883dbcfe289cb --- /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 bafcd8bdffff9..b8e45cde3c18b 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.painlesserror._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md index 051414eac7585..5f43f8477cb9f 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `PainlessError` class Signature: ```typescript -constructor(err: IEsError, request: IKibanaSearchRequest); +constructor(err: IEsError); ``` ## Parameters @@ -17,5 +17,4 @@ constructor(err: IEsError, request: IKibanaSearchRequest); | Parameter | Type | Description | | --- | --- | --- | | err | IEsError | | -| request | IKibanaSearchRequest | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md index 6ab32f3fb1dfa..c77b8b259136b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md @@ -14,7 +14,7 @@ export declare class PainlessError extends EsError | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(err, request)](./kibana-plugin-plugins-data-public.painlesserror._constructor_.md) | | Constructs a new instance of the PainlessError class | +| [(constructor)(err)](./kibana-plugin-plugins-data-public.painlesserror._constructor_.md) | | Constructs a new instance of the PainlessError class | ## Properties diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md index a0c9b38792825..1ed6059c23062 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md @@ -7,7 +7,7 @@ Signature: ```typescript -setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; +setup(core: CoreSetup, { bfetch, expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; ``` ## Parameters @@ -15,7 +15,7 @@ setup(core: CoreSetup, { expressio | Parameter | Type | Description | | --- | --- | --- | | core | CoreSetup<DataStartDependencies, DataPublicPluginStart> | | -| { expressions, uiActions, usageCollection } | DataSetupDependencies | | +| { bfetch, expressions, uiActions, usageCollection } | DataSetupDependencies | | Returns: 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 b886aafcfc00f..2fd84730957b6 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/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md index 1c8b6eb41a72e..b5ac4a4e53887 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md @@ -7,7 +7,7 @@ Signature: ```typescript -protected handleSearchError(e: any, request: IKibanaSearchRequest, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; +protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; ``` ## Parameters @@ -15,7 +15,6 @@ protected handleSearchError(e: any, request: IKibanaSearchRequest, timeoutSignal | Parameter | Type | Description | | --- | --- | --- | | e | any | | -| request | IKibanaSearchRequest | | | timeoutSignal | AbortSignal | | | options | ISearchOptions | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md index 40c7055e4c059..5f266e7d8bd8c 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -27,7 +27,7 @@ export declare class SearchInterceptor | Method | Modifiers | Description | | --- | --- | --- | | [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | | -| [handleSearchError(e, request, timeoutSignal, options)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | +| [handleSearchError(e, timeoutSignal, options)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | | [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when cancelPending is called, when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | | [showError(e)](./kibana-plugin-plugins-data-public.searchinterceptor.showerror.md) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.bfetch.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.bfetch.md new file mode 100644 index 0000000000000..5b7c635c71529 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.bfetch.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptorDeps](./kibana-plugin-plugins-data-public.searchinterceptordeps.md) > [bfetch](./kibana-plugin-plugins-data-public.searchinterceptordeps.bfetch.md) + +## SearchInterceptorDeps.bfetch property + +Signature: + +```typescript +bfetch: BfetchPublicSetup; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md index 3653394d28b92..543566b783c23 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md @@ -14,6 +14,7 @@ export interface SearchInterceptorDeps | Property | Type | Description | | --- | --- | --- | +| [bfetch](./kibana-plugin-plugins-data-public.searchinterceptordeps.bfetch.md) | BfetchPublicSetup | | | [http](./kibana-plugin-plugins-data-public.searchinterceptordeps.http.md) | CoreSetup['http'] | | | [session](./kibana-plugin-plugins-data-public.searchinterceptordeps.session.md) | ISessionService | | | [startServices](./kibana-plugin-plugins-data-public.searchinterceptordeps.startservices.md) | Promise<[CoreStart, any, unknown]> | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md index 548fa66e6e518..df302e9f3b0d3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md @@ -41,6 +41,7 @@ export declare class SearchSource | [getSearchRequestBody()](./kibana-plugin-plugins-data-public.searchsource.getsearchrequestbody.md) | | Returns body contents of the search request, often referred as query DSL. | | [getSerializedFields()](./kibana-plugin-plugins-data-public.searchsource.getserializedfields.md) | | serializes search source fields (which can later be passed to [ISearchStartSearchSource](./kibana-plugin-plugins-data-public.isearchstartsearchsource.md)) | | [onRequestStart(handler)](./kibana-plugin-plugins-data-public.searchsource.onrequeststart.md) | | Add a handler that will be notified whenever requests start | +| [removeField(field)](./kibana-plugin-plugins-data-public.searchsource.removefield.md) | | remove field | | [serialize()](./kibana-plugin-plugins-data-public.searchsource.serialize.md) | | Serializes the instance to a JSON string and a set of referenced objects. Use this method to get a representation of the search source which can be stored in a saved object.The references returned by this function can be mixed with other references in the same object, however make sure there are no name-collisions. The references will be named kibanaSavedObjectMeta.searchSourceJSON.index and kibanaSavedObjectMeta.searchSourceJSON.filter[<number>].meta.index.Using createSearchSource, the instance can be re-created. | | [setField(field, value)](./kibana-plugin-plugins-data-public.searchsource.setfield.md) | | sets value to a single search source field | | [setFields(newFields)](./kibana-plugin-plugins-data-public.searchsource.setfields.md) | | Internal, do not use. Overrides all search source fields with the new field array. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.removefield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.removefield.md new file mode 100644 index 0000000000000..1e6b63be997ff --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.removefield.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchSource](./kibana-plugin-plugins-data-public.searchsource.md) > [removeField](./kibana-plugin-plugins-data-public.searchsource.removefield.md) + +## SearchSource.removeField() method + +remove field + +Signature: + +```typescript +removeField(field: K): this; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| field | K | | + +Returns: + +`this` + 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 0000000000000..6fda400d09fd0 --- /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.getdefaultsearchparams.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getdefaultsearchparams.md index 3d9191196aaf0..19a4bbbbef86c 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getdefaultsearchparams.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getdefaultsearchparams.md @@ -7,11 +7,7 @@ Signature: ```typescript -export declare function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient): Promise<{ - maxConcurrentShardRequests: number | undefined; - ignoreUnavailable: boolean; - trackTotalHits: boolean; -}>; +export declare function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient): Promise>; ``` ## Parameters @@ -22,9 +18,5 @@ export declare function getDefaultSearchParams(uiSettingsClient: IUiSettingsClie Returns: -`Promise<{ - maxConcurrentShardRequests: number | undefined; - ignoreUnavailable: boolean; - trackTotalHits: boolean; -}>` +`Promise>` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getshardtimeout.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getshardtimeout.md index d7e2a597ff33d..87aa32608eb14 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getshardtimeout.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getshardtimeout.md @@ -7,11 +7,7 @@ Signature: ```typescript -export declare function getShardTimeout(config: SharedGlobalConfig): { - timeout: string; -} | { - timeout?: undefined; -}; +export declare function getShardTimeout(config: SharedGlobalConfig): Pick; ``` ## Parameters @@ -22,9 +18,5 @@ export declare function getShardTimeout(config: SharedGlobalConfig): { Returns: -`{ - timeout: string; -} | { - timeout?: undefined; -}` +`Pick` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.id.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.id.md deleted file mode 100644 index 8e1d5d01bb664..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.id.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IEsRawSearchResponse](./kibana-plugin-plugins-data-server.iesrawsearchresponse.md) > [id](./kibana-plugin-plugins-data-server.iesrawsearchresponse.id.md) - -## IEsRawSearchResponse.id property - -Signature: - -```typescript -id?: string; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_partial.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_partial.md deleted file mode 100644 index da2a57a84ab2f..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_partial.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IEsRawSearchResponse](./kibana-plugin-plugins-data-server.iesrawsearchresponse.md) > [is\_partial](./kibana-plugin-plugins-data-server.iesrawsearchresponse.is_partial.md) - -## IEsRawSearchResponse.is\_partial property - -Signature: - -```typescript -is_partial?: boolean; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_running.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_running.md deleted file mode 100644 index 78b9e07b77890..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_running.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IEsRawSearchResponse](./kibana-plugin-plugins-data-server.iesrawsearchresponse.md) > [is\_running](./kibana-plugin-plugins-data-server.iesrawsearchresponse.is_running.md) - -## IEsRawSearchResponse.is\_running property - -Signature: - -```typescript -is_running?: boolean; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.md deleted file mode 100644 index 306c18dea9b0d..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IEsRawSearchResponse](./kibana-plugin-plugins-data-server.iesrawsearchresponse.md) - -## IEsRawSearchResponse interface - -Signature: - -```typescript -export interface IEsRawSearchResponse extends SearchResponse -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [id](./kibana-plugin-plugins-data-server.iesrawsearchresponse.id.md) | string | | -| [is\_partial](./kibana-plugin-plugins-data-server.iesrawsearchresponse.is_partial.md) | boolean | | -| [is\_running](./kibana-plugin-plugins-data-server.iesrawsearchresponse.is_running.md) | boolean | | - 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 8957f6d0f06b4..c85f294d162bc 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 @@ -34,6 +34,7 @@ | [getTime(indexPattern, timeRange, options)](./kibana-plugin-plugins-data-server.gettime.md) | | | [parseInterval(interval)](./kibana-plugin-plugins-data-server.parseinterval.md) | | | [plugin(initializerContext)](./kibana-plugin-plugins-data-server.plugin.md) | Static code to be shared externally | +| [searchUsageObserver(logger, usage)](./kibana-plugin-plugins-data-server.searchusageobserver.md) | Rxjs observer for easily doing tap(searchUsageObserver(logger, usage)) in an rxjs chain. | | [shouldReadFieldFromDocValues(aggregatable, esType)](./kibana-plugin-plugins-data-server.shouldreadfieldfromdocvalues.md) | | | [usageProvider(core)](./kibana-plugin-plugins-data-server.usageprovider.md) | | @@ -45,7 +46,6 @@ | [EsQueryConfig](./kibana-plugin-plugins-data-server.esqueryconfig.md) | | | [FieldDescriptor](./kibana-plugin-plugins-data-server.fielddescriptor.md) | | | [FieldFormatConfig](./kibana-plugin-plugins-data-server.fieldformatconfig.md) | | -| [IEsRawSearchResponse](./kibana-plugin-plugins-data-server.iesrawsearchresponse.md) | | | [IEsSearchRequest](./kibana-plugin-plugins-data-server.iessearchrequest.md) | | | [IFieldSubType](./kibana-plugin-plugins-data-server.ifieldsubtype.md) | | | [IFieldType](./kibana-plugin-plugins-data-server.ifieldtype.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.plugin.setup.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md index 43129891c5412..b90018c3d9cdd 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md @@ -7,7 +7,7 @@ Signature: ```typescript -setup(core: CoreSetup, { expressions, usageCollection }: DataPluginSetupDependencies): { +setup(core: CoreSetup, { bfetch, expressions, usageCollection }: DataPluginSetupDependencies): { __enhance: (enhancements: DataEnhancements) => void; search: ISearchSetup; fieldFormats: { @@ -21,7 +21,7 @@ setup(core: CoreSetup, { expressio | Parameter | Type | Description | | --- | --- | --- | | core | CoreSetup<DataPluginStartDependencies, DataPluginStart> | | -| { expressions, usageCollection } | DataPluginSetupDependencies | | +| { bfetch, expressions, usageCollection } | DataPluginSetupDependencies | | Returns: 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 e2a71a7badd4d..4f8a0beefa421 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 @@ -8,24 +8,6 @@ ```typescript search: { - esSearch: { - utils: { - doSearch: (searchMethod: () => Promise, abortSignal?: AbortSignal | undefined) => import("rxjs").Observable; - shimAbortSignal: >(promise: T, signal: AbortSignal | undefined) => T; - trackSearchStatus: = import("./search").IEsSearchResponse>>(logger: import("src/core/server").Logger, usage?: import("./search").SearchUsage | undefined) => import("rxjs").UnaryFunction, import("rxjs").Observable>; - includeTotalLoaded: () => import("rxjs").OperatorFunction>, { - total: number; - loaded: number; - id?: string | undefined; - isRunning?: boolean | undefined; - isPartial?: boolean | undefined; - rawResponse: import("elasticsearch").SearchResponse; - }>; - toKibanaSearchResponse: = import("../common").IEsRawSearchResponse, KibanaResponse_1 extends import("../common").IKibanaSearchResponse = import("../common").IKibanaSearchResponse>() => import("rxjs").OperatorFunction, KibanaResponse_1>; - getTotalLoaded: typeof getTotalLoaded; - toSnakeCase: typeof toSnakeCase; - }; - }; aggs: { CidrMask: typeof CidrMask; dateHistogramInterval: typeof dateHistogramInterval; @@ -52,6 +34,7 @@ search: { siblingPipelineType: string; termsAggFilter: string[]; toAbsoluteDates: typeof toAbsoluteDates; + calcAutoIntervalLessThan: typeof calcAutoIntervalLessThan; }; getRequestInspectorStats: typeof getRequestInspectorStats; getResponseInspectorStats: typeof getResponseInspectorStats; diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchusageobserver.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchusageobserver.md new file mode 100644 index 0000000000000..5e03bb381527e --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchusageobserver.md @@ -0,0 +1,31 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [searchUsageObserver](./kibana-plugin-plugins-data-server.searchusageobserver.md) + +## searchUsageObserver() function + +Rxjs observer for easily doing `tap(searchUsageObserver(logger, usage))` in an rxjs chain. + +Signature: + +```typescript +export declare function searchUsageObserver(logger: Logger, usage?: SearchUsage): { + next(response: IEsSearchResponse): void; + error(): void; +}; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| logger | Logger | | +| usage | SearchUsage | | + +Returns: + +`{ + next(response: IEsSearchResponse): void; + error(): void; +}` + 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 fb6ba7ee2621c..fcccd3f6b9618 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 7f7d5792ba684..12c663273bd8c 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 2dfc67d2af5fa..54eecad0deb50 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 0000000000000..2986b81fc67c5 --- /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 0000000000000..8cddec1a5359c --- /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 ab0273be71402..a65e025451636 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 0000000000000..16db25ab244f6 --- /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 ccf6271f712b9..b1496386944fa 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/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 13c1d20552fa1..3c0e63fae0daa 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -9,7 +9,7 @@ Alerts and actions are enabled by default in {kib}, but require you configure th . <>. . <>. -. <>. +. If you are using an *on-premises* Elastic Stack deployment, <>. You can configure the following settings in the `kibana.yml` file. diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index efc7a1b930932..c22d4466ee09e 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -214,10 +214,12 @@ Please use the `defaultRoute` advanced setting instead. The default application to load. *Default: `"home"`* |[[kibana-index]] `kibana.index:` - | {kib} uses an index in {es} to store saved searches, visualizations, and + | *deprecated* This setting is deprecated and will be removed in 8.0. Multitenancy by changing +`kibana.index` will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy[8.0 Breaking Changes] +for more details. {kib} uses an index in {es} to store saved searches, visualizations, and dashboards. {kib} creates a new index if the index doesn’t already exist. If you configure a custom index, the name must be lowercase, and conform to the -{es} {ref}/indices-create-index.html[index name limitations]. +{es} {ref}/indices-create-index.html[index name limitations]. *Default: `".kibana"`* | `kibana.autocompleteTimeout:` {ess-icon} diff --git a/docs/user/dashboard/tutorials.asciidoc b/docs/user/dashboard/tutorials.asciidoc index b04de5fd0da6f..d3abb849af819 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 0000000000000..a294e02f07848 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 0000000000000..e73a837fa816b 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 0000000000000..d0c84fe76ba55 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 0000000000000..486ef6c362438 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 0000000000000..d2c83371b107b Binary files /dev/null and b/docs/visualize/images/vega_lite_tutorial_7.png differ diff --git a/package.json b/package.json index 8e94e5277b8e3..1febfc2380b7a 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", @@ -160,7 +160,6 @@ "apollo-server-core": "^1.3.6", "apollo-server-errors": "^2.0.2", "apollo-server-hapi": "^1.3.6", - "apollo-server-module-graphiql": "^1.3.4", "archiver": "^3.1.1", "axios": "^0.19.2", "bluebird": "3.5.5", @@ -243,7 +242,7 @@ "moment": "^2.24.0", "moment-duration-format": "^2.3.2", "moment-timezone": "^0.5.27", - "monaco-editor": "~0.17.0", + "monaco-editor": "^0.17.0", "mustache": "^2.3.2", "ngreact": "^0.5.1", "nock": "12.0.3", @@ -261,7 +260,6 @@ "pdfmake": "^0.1.65", "pegjs": "0.10.0", "pngjs": "^3.4.0", - "podium": "^3.1.2", "prop-types": "^15.7.2", "proper-lockfile": "^3.2.0", "proxy-from-env": "1.0.0", @@ -590,7 +588,7 @@ "babel-loader": "^8.0.6", "babel-plugin-add-module-exports": "^1.0.2", "babel-plugin-istanbul": "^6.0.0", - "babel-plugin-require-context-hook": "npm:babel-plugin-require-context-hook-babel7@1.0.0", + "babel-plugin-require-context-hook": "^1.0.0", "babel-plugin-styled-components": "^1.10.7", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "backport": "5.6.0", @@ -654,7 +652,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", @@ -776,7 +774,7 @@ "react-fast-compare": "^2.0.4", "react-grid-layout": "^0.16.2", "react-markdown": "^4.3.1", - "react-monaco-editor": "~0.27.0", + "react-monaco-editor": "^0.27.0", "react-popper-tooltip": "^2.10.1", "react-resize-detector": "^4.2.0", "react-reverse-portal": "^1.0.4", @@ -827,7 +825,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-config/src/__mocks__/env.ts b/packages/kbn-config/src/__mocks__/env.ts index f37ac14e60235..8b7475680ecf5 100644 --- a/packages/kbn-config/src/__mocks__/env.ts +++ b/packages/kbn-config/src/__mocks__/env.ts @@ -30,7 +30,6 @@ export function getEnvOptions(options: DeepPartial = {}): EnvOptions configs: options.configs || [], cliArgs: { dev: true, - open: false, quiet: false, silent: false, watch: false, @@ -43,7 +42,6 @@ export function getEnvOptions(options: DeepPartial = {}): EnvOptions runExamples: false, ...(options.cliArgs || {}), }, - isDevClusterMaster: - options.isDevClusterMaster !== undefined ? options.isDevClusterMaster : false, + isDevCliParent: options.isDevCliParent !== undefined ? options.isDevCliParent : false, }; } diff --git a/packages/kbn-config/src/__snapshots__/env.test.ts.snap b/packages/kbn-config/src/__snapshots__/env.test.ts.snap index 005fa6e680f8b..9236c83f9c921 100644 --- a/packages/kbn-config/src/__snapshots__/env.test.ts.snap +++ b/packages/kbn-config/src/__snapshots__/env.test.ts.snap @@ -10,7 +10,6 @@ Env { "disableOptimizer": true, "dist": false, "envName": "development", - "open": false, "oss": false, "quiet": false, "repl": false, @@ -23,7 +22,7 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevClusterMaster": false, + "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": true, @@ -56,7 +55,6 @@ Env { "disableOptimizer": true, "dist": false, "envName": "production", - "open": false, "oss": false, "quiet": false, "repl": false, @@ -69,7 +67,7 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevClusterMaster": false, + "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": false, @@ -101,7 +99,6 @@ Env { "dev": true, "disableOptimizer": true, "dist": false, - "open": false, "oss": false, "quiet": false, "repl": false, @@ -114,7 +111,7 @@ Env { "/test/cwd/config/kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevClusterMaster": true, + "isDevCliParent": true, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": true, @@ -146,7 +143,6 @@ Env { "dev": false, "disableOptimizer": true, "dist": false, - "open": false, "oss": false, "quiet": false, "repl": false, @@ -159,7 +155,7 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevClusterMaster": false, + "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": false, @@ -191,7 +187,6 @@ Env { "dev": false, "disableOptimizer": true, "dist": false, - "open": false, "oss": false, "quiet": false, "repl": false, @@ -204,7 +199,7 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevClusterMaster": false, + "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": false, @@ -236,7 +231,6 @@ Env { "dev": false, "disableOptimizer": true, "dist": false, - "open": false, "oss": false, "quiet": false, "repl": false, @@ -249,7 +243,7 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/some/home/dir", - "isDevClusterMaster": false, + "isDevCliParent": false, "logDir": "/some/home/dir/log", "mode": Object { "dev": false, diff --git a/packages/kbn-config/src/env.test.ts b/packages/kbn-config/src/env.test.ts index 1613a90951d40..5aee33e6fda5e 100644 --- a/packages/kbn-config/src/env.test.ts +++ b/packages/kbn-config/src/env.test.ts @@ -47,7 +47,7 @@ test('correctly creates default environment in dev mode.', () => { REPO_ROOT, getEnvOptions({ configs: ['/test/cwd/config/kibana.yml'], - isDevClusterMaster: true, + isDevCliParent: true, }) ); diff --git a/packages/kbn-config/src/env.ts b/packages/kbn-config/src/env.ts index e7b4658262235..4ae8d7b7f9aa5 100644 --- a/packages/kbn-config/src/env.ts +++ b/packages/kbn-config/src/env.ts @@ -26,7 +26,7 @@ import { PackageInfo, EnvironmentMode } from './types'; export interface EnvOptions { configs: string[]; cliArgs: CliArgs; - isDevClusterMaster: boolean; + isDevCliParent: boolean; } /** @internal */ @@ -38,7 +38,6 @@ export interface CliArgs { watch: boolean; repl: boolean; basePath: boolean; - open: boolean; oss: boolean; /** @deprecated use disableOptimizer to know if the @kbn/optimizer is disabled in development */ optimize?: boolean; @@ -102,10 +101,10 @@ export class Env { public readonly configs: readonly string[]; /** - * Indicates that this Kibana instance is run as development Node Cluster master. + * Indicates that this Kibana instance is running in the parent process of the dev cli. * @internal */ - public readonly isDevClusterMaster: boolean; + public readonly isDevCliParent: boolean; /** * @internal @@ -123,7 +122,7 @@ export class Env { this.cliArgs = Object.freeze(options.cliArgs); this.configs = Object.freeze(options.configs); - this.isDevClusterMaster = options.isDevClusterMaster; + this.isDevCliParent = options.isDevCliParent; const isDevMode = this.cliArgs.dev || this.cliArgs.envName === 'development'; this.mode = Object.freeze({ diff --git a/packages/kbn-es-archiver/package.json b/packages/kbn-es-archiver/package.json index 3cd07668635f1..fed4844011158 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 1194637b1ff89..9a270fd3820f0 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 c2f5f18a07e9b..11d47437126b0 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 470a566a6eef0..8abc24d527041 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 84a0ce09936d0..60a04a6123c92 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 ae23ef21fb79f..915f0906eb0d3 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 87df07fe865bd..d65f5a5b23cd0 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 044a0e82d9df2..91c38d0dd1438 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 25b8fe46a81fc..deaea5cd4532e 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 3cd698c3f82c3..74c9561407c8d 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 9236a618aa01a..65b01f38eb83e 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 3c5fc742a6e10..074333eb6028f 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 2b8eac5c27122..ac85681610c6c 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 27c28b2229aec..b1a83046f40d6 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 551b744415c83..3c9d866700005 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 cb3746c015dad..d2c9f1274e60f 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 b23ff2e4e52ac..cf67ee2071c10 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 1498334013d1a..0000000000000 --- 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 03dd894067afc..0000000000000 --- 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 878d645d9b4a7..0000000000000 --- 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 be0768316b293..0000000000000 --- 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 28b7f2588628e..0000000000000 --- 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/intersperse_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.test.js deleted file mode 100644 index e11b36d77106a..0000000000000 --- 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 eb2e3d3087d4a..0000000000000 --- 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 12e20696b0510..0000000000000 --- 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/map_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/map_stream.test.js deleted file mode 100644 index d86da178f0c1b..0000000000000 --- 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/promise_from_streams.test.js b/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.test.js deleted file mode 100644 index e4d9835106f12..0000000000000 --- 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 fefb18be14780..0000000000000 --- 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 2c073f67f82a8..0000000000000 --- 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 d9458e9a11c33..0000000000000 --- 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 01b89f93e5af0..0000000000000 --- 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 fe2ba1fcdf31c..0000000000000 --- 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 e0736d220ba5c..0000000000000 --- 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 1c9b59449bd92..0000000000000 --- 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 9311b3e2a77b3..808fedc788d94 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/legacy_logging_server.ts b/packages/kbn-legacy-logging/src/legacy_logging_server.ts index 45e4bda0b007c..1b13eda44fff2 100644 --- a/packages/kbn-legacy-logging/src/legacy_logging_server.ts +++ b/packages/kbn-legacy-logging/src/legacy_logging_server.ts @@ -18,7 +18,7 @@ */ import { ServerExtType, Server } from '@hapi/hapi'; -import Podium from 'podium'; +import Podium from '@hapi/podium'; import { setupLogging } from './setup_logging'; import { attachMetaData } from './metadata'; import { legacyLoggingConfigSchema } from './schema'; 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 f762daf01e5fa..b31c45535e1a9 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 0ed233228c1fd..d11a4a038d49a 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/log_format_string.ts b/packages/kbn-legacy-logging/src/log_format_string.ts index 3f024fac55119..b4217c37b960e 100644 --- a/packages/kbn-legacy-logging/src/log_format_string.ts +++ b/packages/kbn-legacy-logging/src/log_format_string.ts @@ -54,7 +54,7 @@ const type = _.memoize((t: string) => { return color(t)(_.pad(t, 7).slice(0, 7)); }); -const workerType = process.env.kbnWorkerType ? `${type(process.env.kbnWorkerType)} ` : ''; +const prefix = process.env.isDevCliChild ? `${type('server')} ` : ''; export class KbnLoggerStringFormat extends BaseLogFormat { format(data: Record) { @@ -71,6 +71,6 @@ export class KbnLoggerStringFormat extends BaseLogFormat { return s + `[${color(t)(t)}]`; }, ''); - return `${workerType}${type(data.type)} [${time}] ${tags} ${msg}`; + return `${prefix}${type(data.type)} [${time}] ${tags} ${msg}`; } } diff --git a/packages/kbn-legacy-logging/src/rotate/index.ts b/packages/kbn-legacy-logging/src/rotate/index.ts index 2387fc530e58b..9a83c625b9431 100644 --- a/packages/kbn-legacy-logging/src/rotate/index.ts +++ b/packages/kbn-legacy-logging/src/rotate/index.ts @@ -17,7 +17,6 @@ * under the License. */ -import { isMaster, isWorker } from 'cluster'; import { Server } from '@hapi/hapi'; import { LogRotator } from './log_rotator'; import { LegacyLoggingConfig } from '../schema'; @@ -30,12 +29,6 @@ export async function setupLoggingRotate(server: Server, config: LegacyLoggingCo return; } - // We just want to start the logging rotate service once - // and we choose to use the master (prod) or the worker server (dev) - if (!isMaster && isWorker && process.env.kbnWorkerType !== 'server') { - return; - } - // We don't want to run logging rotate server if // we are not logging to a file if (config.dest === 'stdout') { diff --git a/packages/kbn-legacy-logging/src/rotate/log_rotator.ts b/packages/kbn-legacy-logging/src/rotate/log_rotator.ts index 54181e30d6007..fc2c088f01dde 100644 --- a/packages/kbn-legacy-logging/src/rotate/log_rotator.ts +++ b/packages/kbn-legacy-logging/src/rotate/log_rotator.ts @@ -18,7 +18,6 @@ */ import * as chokidar from 'chokidar'; -import { isMaster } from 'cluster'; import fs from 'fs'; import { Server } from '@hapi/hapi'; import { throttle } from 'lodash'; @@ -351,22 +350,14 @@ export class LogRotator { } _sendReloadLogConfigSignal() { - if (isMaster) { - (process as NodeJS.EventEmitter).emit('SIGHUP'); + if (!process.env.isDevCliChild || !process.send) { + process.emit('SIGHUP', 'SIGHUP'); return; } // Send a special message to the cluster manager // so it can forward it correctly // It will only run when we are under cluster mode (not under a production environment) - if (!process.send) { - this.log( - ['error', 'logging:rotate'], - 'For some unknown reason process.send is not defined, the rotation was not successful' - ); - return; - } - process.send(['RELOAD_LOGGING_CONFIG_FROM_SERVER_WORKER']); } } 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 0f37a13f8a478..0000000000000 --- 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-optimizer/src/integration_tests/basic_optimization.test.ts b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts index 46660f0dd958b..16baaddcb84b2 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -233,6 +233,10 @@ it('uses cache on second run and exist cleanly', async () => { }); it('prepares assets for distribution', async () => { + if (process.env.CODE_COVERAGE) { + // test fails when testing coverage because source includes instrumentation, so skip it + return; + } const config = OptimizerConfig.create({ repoRoot: MOCK_REPO_DIR, pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins'), Path.resolve(MOCK_REPO_DIR, 'x-pack')], diff --git a/packages/kbn-utils/package.json b/packages/kbn-utils/package.json index a07be96f0d4d8..0859faa7ed0ad 100644 --- a/packages/kbn-utils/package.json +++ b/packages/kbn-utils/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "private": true, "scripts": { - "build": "../../node_modules/.bin/tsc", + "build": "rm -rf target && ../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" }, diff --git a/packages/kbn-utils/src/index.ts b/packages/kbn-utils/src/index.ts index 7a894d72d5624..30362112140aa 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/rfcs/text/0013_saved_object_migrations.md b/rfcs/text/0013_saved_object_migrations.md index 1a0967d110d06..6e125c28c04c0 100644 --- a/rfcs/text/0013_saved_object_migrations.md +++ b/rfcs/text/0013_saved_object_migrations.md @@ -13,6 +13,7 @@ - [4.2.1 Idempotent migrations performed without coordination](#421-idempotent-migrations-performed-without-coordination) - [4.2.1.1 Restrictions](#4211-restrictions) - [4.2.1.2 Migration algorithm: Cloned index per version](#4212-migration-algorithm-cloned-index-per-version) + - [Known weaknesses:](#known-weaknesses) - [4.2.1.3 Upgrade and rollback procedure](#4213-upgrade-and-rollback-procedure) - [4.2.1.4 Handling documents that belong to a disabled plugin](#4214-handling-documents-that-belong-to-a-disabled-plugin) - [5. Alternatives](#5-alternatives) @@ -192,26 +193,24 @@ id's deterministically with e.g. UUIDv5. ### 4.2.1.2 Migration algorithm: Cloned index per version Note: - The description below assumes the migration algorithm is released in 7.10.0. - So < 7.10.0 will use `.kibana` and >= 7.10.0 will use `.kibana_current`. + So >= 7.10.0 will use the new algorithm. - We refer to the alias and index that outdated nodes use as the source alias and source index. - Every version performs a migration even if mappings or documents aren't outdated. -1. Locate the source index by fetching aliases (including `.kibana` for - versions prior to v7.10.0) +1. Locate the source index by fetching kibana indices: ``` - GET '/_alias/.kibana_current,.kibana_7.10.0,.kibana' + GET '/_indices/.kibana,.kibana_7.10.0' ``` The source index is: - 1. the index the `.kibana_current` alias points to, or if it doesn’t exist, - 2. the index the `.kibana` alias points to, or if it doesn't exist, - 3. the v6.x `.kibana` index + 1. the index the `.kibana` alias points to, or if it doesn't exist, + 2. the v6.x `.kibana` index If none of the aliases exists, this is a new Elasticsearch cluster and no migrations are necessary. Create the `.kibana_7.10.0_001` index with the - following aliases: `.kibana_current` and `.kibana_7.10.0`. + following aliases: `.kibana` and `.kibana_7.10.0`. 2. If the source is a < v6.5 `.kibana` index or < 7.4 `.kibana_task_manager` index prepare the legacy index for a migration: 1. Mark the legacy index as read-only and wait for all in-flight operations to drain (requires https://github.com/elastic/elasticsearch/pull/58094). This prevents any further writes from outdated nodes. Assuming this API is similar to the existing `//_close` API, we expect to receive `"acknowledged" : true` and `"shards_acknowledged" : true`. If all shards don’t acknowledge within the timeout, retry the operation until it succeeds. @@ -235,13 +234,13 @@ Note: atomically so that other Kibana instances will always see either a `.kibana` index or an alias, but never neither. 6. Use the cloned `.kibana_pre6.5.0_001` as the source for the rest of the migration algorithm. -3. If `.kibana_current` and `.kibana_7.10.0` both exists and are pointing to the same index this version's migration has already been completed. +3. If `.kibana` and `.kibana_7.10.0` both exists and are pointing to the same index this version's migration has already been completed. 1. Because the same version can have plugins enabled at any point in time, perform the mappings update in step (6) and migrate outdated documents with step (7). 2. Skip to step (9) to start serving traffic. 4. Fail the migration if: - 1. `.kibana_current` is pointing to an index that belongs to a later version of Kibana .e.g. `.kibana_7.12.0_001` + 1. `.kibana` is pointing to an index that belongs to a later version of Kibana .e.g. `.kibana_7.12.0_001` 2. (Only in 8.x) The source index contains documents that belong to an unknown Saved Object type (from a disabled plugin). Log an error explaining that the plugin that created these documents needs to be enabled again or that these objects should be deleted. See section (4.2.1.4). 5. Mark the source index as read-only and wait for all in-flight operations to drain (requires https://github.com/elastic/elasticsearch/pull/58094). This prevents any further writes from outdated nodes. Assuming this API is similar to the existing `//_close` API, we expect to receive `"acknowledged" : true` and `"shards_acknowledged" : true`. If all shards don’t acknowledge within the timeout, retry the operation until it succeeds. 6. Clone the source index into a new target index which has writes enabled. All nodes on the same version will use the same fixed index name e.g. `.kibana_7.10.0_001`. The `001` postfix isn't used by Kibana, but allows for re-indexing an index should this be required by an Elasticsearch upgrade. E.g. re-index `.kibana_7.10.0_001` into `.kibana_7.10.0_002` and point the `.kibana_7.10.0` alias to `.kibana_7.10.0_002`. @@ -257,24 +256,62 @@ Note: 8. Transform documents by reading batches of outdated documents from the target index then transforming and updating them with optimistic concurrency control. 1. Ignore any version conflict errors. 2. If a document transform throws an exception, add the document to a failure list and continue trying to transform all other documents. If any failures occured, log the complete list of documents that failed to transform. Fail the migration. -9. Mark the migration as complete by doing a single atomic operation (requires https://github.com/elastic/elasticsearch/pull/58100) that: - 3. Checks that `.kibana_current` alias is still pointing to the source index - 4. Points the `.kibana_7.10.0` and `.kibana_current` aliases to the target index. - 5. If this fails with a "required alias [.kibana_current] does not exist" error fetch `.kibana_current` again: - 1. If `.kibana_current` is _not_ pointing to our target index fail the migration. - 2. If `.kibana_current` is pointing to our target index the migration has succeeded and we can proceed to step (9). -10. Start serving traffic. - -This algorithm shares a weakness with our existing migration algorithm -(since v7.4). When the task manager index gets reindexed a reindex script is -applied. Because we delete the original task manager index there is no way to -rollback a failed task manager migration without a snapshot. +9. Mark the migration as complete. This is done as a single atomic + operation (requires https://github.com/elastic/elasticsearch/pull/58100) + to guarantees when multiple versions of Kibana are performing the + migration in parallel, only one version will win. E.g. if 7.11 and 7.12 + are started in parallel and migrate from a 7.9 index, either 7.11 or 7.12 + should succeed and accept writes, but not both. + 3. Checks that `.kibana` alias is still pointing to the source index + 4. Points the `.kibana_7.10.0` and `.kibana` aliases to the target index. + 5. If this fails with a "required alias [.kibana] does not exist" error fetch `.kibana` again: + 1. If `.kibana` is _not_ pointing to our target index fail the migration. + 2. If `.kibana` is pointing to our target index the migration has succeeded and we can proceed to step (10). +10. Start serving traffic. All saved object reads/writes happen through the + version-specific alias `.kibana_7.10.0`. Together with the limitations, this algorithm ensures that migrations are idempotent. If two nodes are started simultaneously, both of them will start transforming documents in that version's target index, but because migrations are idempotent, it doesn’t matter which node’s writes win. +#### Known weaknesses: +(Also present in our existing migration algorithm since v7.4) +When the task manager index gets reindexed a reindex script is applied. +Because we delete the original task manager index there is no way to rollback +a failed task manager migration without a snapshot. Although losing the task +manager data has a fairly low impact. + +(Also present in our existing migration algorithm since v6.5) +If the outdated instance isn't shutdown before starting the migration, the +following data-loss scenario is possible: +1. Upgrade a 7.9 index without shutting down the 7.9 nodes +2. Kibana v7.10 performs a migration and after completing points `.kibana` + alias to `.kibana_7.11.0_001` +3. Kibana v7.9 writes unmigrated documents into `.kibana`. +4. Kibana v7.10 performs a query based on the updated mappings of documents so + results potentially don't match the acknowledged write from step (3). + +Note: + - Data loss won't occur if both nodes have the updated migration algorithm + proposed in this RFC. It is only when one of the nodes use the existing + algorithm that data loss is possible. + - Once v7.10 is restarted it will transform any outdated documents making + these visible to queries again. + +It is possible to work around this weakness by introducing a new alias such as +`.kibana_current` so that after a migration the `.kibana` alias will continue +to point to the outdated index. However, we decided to keep using the +`.kibana` alias despite this weakness for the following reasons: + - Users might rely on `.kibana` alias for snapshots, so if this alias no + longer points to the latest index their snapshots would no longer backup + kibana's latest data. + - Introducing another alias introduces complexity for users and support. + The steps to diagnose, fix or rollback a failed migration will deviate + depending on the 7.x version of Kibana you are using. + - The existing Kibana documentation clearly states that outdated nodes should + be shutdown, this scenario has never been supported by Kibana. +
In the future, this algorithm could enable (2.6) "read-only functionality during the downtime window" but this is outside of the scope of this RFC. @@ -303,12 +340,9 @@ To rollback to a previous version of Kibana without a snapshot: (Assumes the migration to 7.11.0 failed) 1. Shutdown all Kibana nodes. 2. Remove the index created by the failed Kibana migration by using the version-specific alias e.g. `DELETE /.kibana_7.11.0` -3. Identify the rollback index: - 1. If rolling back to a Kibana version < 7.10.0 use `.kibana` - 2. If rolling back to a Kibana version >= 7.10.0 use the version alias of the Kibana version you wish to rollback to e.g. `.kibana_7.10.0` -4. Point the `.kibana_current` alias to the rollback index. -5. Remove the write block from the rollback index. -6. Start the rollback Kibana nodes. All running Kibana nodes should be on the same rollback version, have the same plugins enabled and use the same configuration. +3. Remove the write block from the rollback index using the `.kibana` alias + `PUT /.kibana/_settings {"index.blocks.write": false}` +4. Start the rollback Kibana nodes. All running Kibana nodes should be on the same rollback version, have the same plugins enabled and use the same configuration. ### 4.2.1.4 Handling documents that belong to a disabled plugin It is possible for a plugin to create documents in one version of Kibana, but then when upgrading Kibana to a newer version, that plugin is disabled. Because the plugin is disabled it cannot register it's Saved Objects type including the mappings or any migration transformation functions. These "orphan" documents could cause future problems: @@ -378,7 +412,7 @@ There are several approaches we could take to dealing with these orphan document deterministically perform the delete and re-clone operation without coordination. -5. Transform outdated documents (step 7) on every startup +5. Transform outdated documents (step 8) on every startup Advantages: - Outdated documents belonging to disabled plugins will be upgraded as soon as the plugin is enabled again. diff --git a/src/apm.js b/src/apm.js index bde37fa006c61..4f5fe29cbb5fa 100644 --- a/src/apm.js +++ b/src/apm.js @@ -30,10 +30,6 @@ let apmConfig; const isKibanaDistributable = Boolean(build && build.distributable === true); module.exports = function (serviceName = name) { - if (process.env.kbnWorkerType === 'optmzr') { - return; - } - apmConfig = loadConfiguration(process.argv, ROOT_DIR, isKibanaDistributable); const conf = apmConfig.getConfig(serviceName); const apm = require('elastic-apm-node'); diff --git a/src/cli/cluster/cluster_manager.test.ts b/src/cli/cluster/cluster_manager.test.ts index a8e139533d397..1d2986e742527 100644 --- a/src/cli/cluster/cluster_manager.test.ts +++ b/src/cli/cluster/cluster_manager.test.ts @@ -38,7 +38,6 @@ import { Worker } from './worker'; const CLI_ARGS: SomeCliArgs = { disableOptimizer: true, - open: false, oss: false, quiet: false, repl: false, diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts index 931650a67687c..b0f7cded938dd 100644 --- a/src/cli/cluster/cluster_manager.ts +++ b/src/cli/cluster/cluster_manager.ts @@ -18,10 +18,8 @@ */ import { resolve } from 'path'; -import { format as formatUrl } from 'url'; import Fs from 'fs'; -import opn from 'opn'; import { REPO_ROOT } from '@kbn/utils'; import { FSWatcher } from 'chokidar'; import * as Rx from 'rxjs'; @@ -35,15 +33,12 @@ import { BasePathProxyServer } from '../../core/server/http'; import { Log } from './log'; import { Worker } from './worker'; -process.env.kbnWorkerType = 'managr'; - export type SomeCliArgs = Pick< CliArgs, | 'quiet' | 'silent' | 'repl' | 'disableOptimizer' - | 'open' | 'watch' | 'oss' | 'runExamples' @@ -52,7 +47,7 @@ export type SomeCliArgs = Pick< >; const firstAllTrue = (...sources: Array>) => - Rx.combineLatest(...sources).pipe( + Rx.combineLatest(sources).pipe( filter((values) => values.every((v) => v === true)), take(1), mapTo(undefined) @@ -78,6 +73,19 @@ export class ClusterManager { this.inReplMode = !!opts.repl; this.basePathProxy = basePathProxy; + if (!this.basePathProxy) { + this.log.warn( + '====================================================================================================' + ); + this.log.warn( + 'no-base-path', + 'Running Kibana in dev mode with --no-base-path disables several useful features and is not recommended' + ); + this.log.warn( + '====================================================================================================' + ); + } + // run @kbn/optimizer and write it's state to kbnOptimizerReady$ if (opts.disableOptimizer) { this.kbnOptimizerReady$.next(true); @@ -146,17 +154,6 @@ export class ClusterManager { }); }); - if (opts.open) { - this.setupOpen( - formatUrl({ - protocol: config.get('server.ssl.enabled') ? 'https' : 'http', - hostname: config.get('server.host'), - port: config.get('server.port'), - pathname: this.basePathProxy ? this.basePathProxy.basePath : '', - }) - ); - } - if (opts.watch) { const pluginPaths = config.get('plugins.paths'); const scanDirs = [ @@ -208,14 +205,6 @@ export class ClusterManager { } } - setupOpen(openUrl: string) { - firstAllTrue(this.serverReady$, this.kbnOptimizerReady$) - .toPromise() - .then(() => { - opn(openUrl); - }); - } - setupWatching(extraPaths: string[], pluginInternalDirsIgnore: string[]) { // eslint-disable-next-line @typescript-eslint/no-var-requires const chokidar = require('chokidar'); diff --git a/src/cli/cluster/worker.ts b/src/cli/cluster/worker.ts index d28065765070b..26b2a643e5373 100644 --- a/src/cli/cluster/worker.ts +++ b/src/cli/cluster/worker.ts @@ -56,7 +56,6 @@ export class Worker extends EventEmitter { private readonly clusterBinder: BinderFor; private readonly processBinder: BinderFor; - private type: string; private title: string; private log: any; private forkBinder: BinderFor | null = null; @@ -76,7 +75,6 @@ export class Worker extends EventEmitter { super(); this.log = opts.log; - this.type = opts.type; this.title = opts.title || opts.type; this.watch = opts.watch !== false; this.startCount = 0; @@ -88,7 +86,7 @@ export class Worker extends EventEmitter { this.env = { NODE_OPTIONS: process.env.NODE_OPTIONS || '', - kbnWorkerType: this.type, + isDevCliChild: 'true', kbnWorkerArgv: JSON.stringify([...(opts.baseArgv || baseArgv), ...(opts.argv || [])]), ELASTIC_APM_SERVICE_NAME: opts.apmServiceName || '', }; diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index f344d3b70ed9d..61f880d80633d 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -43,7 +43,7 @@ function canRequire(path) { } const CLUSTER_MANAGER_PATH = resolve(__dirname, '../cluster/cluster_manager'); -const CAN_CLUSTER = canRequire(CLUSTER_MANAGER_PATH); +const DEV_MODE_SUPPORTED = canRequire(CLUSTER_MANAGER_PATH); const REPL_PATH = resolve(__dirname, '../repl'); const CAN_REPL = canRequire(REPL_PATH); @@ -189,10 +189,9 @@ export default function (program) { ); } - if (CAN_CLUSTER) { + if (DEV_MODE_SUPPORTED) { command .option('--dev', 'Run the server with development mode defaults') - .option('--open', 'Open a browser window to the base url after the server is started') .option('--ssl', 'Run the dev server using HTTPS') .option('--dist', 'Use production assets from kbn/optimizer') .option( @@ -222,7 +221,6 @@ export default function (program) { configs: [].concat(opts.config || []), cliArgs: { dev: !!opts.dev, - open: !!opts.open, envName: unknownOptions.env ? unknownOptions.env.name : undefined, quiet: !!opts.quiet, silent: !!opts.silent, @@ -242,7 +240,7 @@ export default function (program) { dist: !!opts.dist, }, features: { - isClusterModeSupported: CAN_CLUSTER, + isCliDevModeSupported: DEV_MODE_SUPPORTED, isReplModeSupported: CAN_REPL, }, applyConfigOverrides: (rawConfig) => applyConfigOverrides(rawConfig, opts, unknownOptions), diff --git a/src/cli_keystore/add.js b/src/cli_keystore/add.js index d88256da1aa59..cec25b631f07b 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 49b962670220c..0000000000000 --- 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 3f34742e44861..0000000000000 --- 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 87c42d9c6dab6..e195bf30c054c 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 afcebc06506c2..cd186f87b3a87 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 82933576bc493..2ccb8ec64f910 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 f6cde54e6f502..50c332dacc34a 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 c5efced0a41e3..175f70a05ec7e 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 cf826eb276252..35381f49543ae 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 beac851aa689c..0000000000000 --- 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 5ed6f76c5a05a..0000000000000 --- 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/bootstrap.ts b/src/core/server/bootstrap.ts index ff1a5c0340c46..6711a8b8987e5 100644 --- a/src/core/server/bootstrap.ts +++ b/src/core/server/bootstrap.ts @@ -18,16 +18,15 @@ */ import chalk from 'chalk'; -import { isMaster } from 'cluster'; import { CliArgs, Env, RawConfigService } from './config'; import { Root } from './root'; import { CriticalError } from './errors'; interface KibanaFeatures { - // Indicates whether we can run Kibana in a so called cluster mode in which - // Kibana is run as a "worker" process together with optimizer "worker" process - // that are orchestrated by the "master" process (dev mode only feature). - isClusterModeSupported: boolean; + // Indicates whether we can run Kibana in dev mode in which Kibana is run as + // a child process together with optimizer "worker" processes that are + // orchestrated by a parent process (dev mode only feature). + isCliDevModeSupported: boolean; // Indicates whether we can run Kibana in REPL mode (dev mode only feature). isReplModeSupported: boolean; @@ -71,7 +70,7 @@ export async function bootstrap({ const env = Env.createDefault(REPO_ROOT, { configs, cliArgs, - isDevClusterMaster: isMaster && cliArgs.dev && features.isClusterModeSupported, + isDevCliParent: cliArgs.dev && features.isCliDevModeSupported && !process.env.isDevCliChild, }); const rawConfigService = new RawConfigService(env.configs, applyConfigOverrides); diff --git a/src/core/server/elasticsearch/client/cluster_client.test.ts b/src/core/server/elasticsearch/client/cluster_client.test.ts index 429fea65704d8..1127619040fff 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/http/base_path_proxy_server.ts b/src/core/server/http/base_path_proxy_server.ts index 42841377e7369..737aab00cff0e 100644 --- a/src/core/server/http/base_path_proxy_server.ts +++ b/src/core/server/http/base_path_proxy_server.ts @@ -199,8 +199,13 @@ export class BasePathProxyServer { const isGet = request.method === 'get'; const isBasepathLike = oldBasePath.length === 3; + const newUrl = Url.format({ + pathname: `${this.httpConfig.basePath}/${kbnPath}`, + query: request.query, + }); + return isGet && isBasepathLike && shouldRedirectFromOldBasePath(kbnPath) - ? responseToolkit.redirect(`${this.httpConfig.basePath}/${kbnPath}`) + ? responseToolkit.redirect(newUrl) : responseToolkit.response('Not Found').code(404); }, method: '*', diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 11cea88fa0dd2..3d55322461288 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -264,7 +264,7 @@ test('does not start http server if process is dev cluster master', async () => const service = new HttpService({ coreId, configService, - env: Env.createDefault(REPO_ROOT, getEnvOptions({ isDevClusterMaster: true })), + env: Env.createDefault(REPO_ROOT, getEnvOptions({ isDevCliParent: true })), logger, }); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 0127a6493e7fd..171a20160d26d 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -158,7 +158,7 @@ export class HttpService * @internal * */ private shouldListen(config: HttpConfig) { - return !this.coreContext.env.isDevClusterMaster && config.autoListen; + return !this.coreContext.env.isDevCliParent && config.autoListen; } public async stop() { diff --git a/src/core/server/kibana_config.test.ts b/src/core/server/kibana_config.test.ts new file mode 100644 index 0000000000000..804c02ae99e4b --- /dev/null +++ b/src/core/server/kibana_config.test.ts @@ -0,0 +1,66 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { config } from './kibana_config'; +import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; + +const CONFIG_PATH = 'kibana'; + +const applyKibanaDeprecations = (settings: Record = {}) => { + const deprecations = config.deprecations!(configDeprecationFactory); + const deprecationMessages: string[] = []; + const _config: any = {}; + _config[CONFIG_PATH] = settings; + const migrated = applyDeprecations( + _config, + deprecations.map((deprecation) => ({ + deprecation, + path: CONFIG_PATH, + })), + (msg) => deprecationMessages.push(msg) + ); + return { + messages: deprecationMessages, + migrated, + }; +}; + +it('set correct defaults ', () => { + const configValue = config.schema.validate({}); + expect(configValue).toMatchInlineSnapshot(` + Object { + "autocompleteTerminateAfter": "PT1M40S", + "autocompleteTimeout": "PT1S", + "enabled": true, + "index": ".kibana", + } + `); +}); + +describe('deprecations', () => { + ['.foo', '.kibana'].forEach((index) => { + it('logs a warning if index is set', () => { + const { messages } = applyKibanaDeprecations({ index }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"kibana.index\\" is deprecated. Multitenancy by changing \\"kibana.index\\" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details", + ] + `); + }); + }); +}); diff --git a/src/core/server/kibana_config.ts b/src/core/server/kibana_config.ts index 17f77a6e9328f..ae6897b6a6ad3 100644 --- a/src/core/server/kibana_config.ts +++ b/src/core/server/kibana_config.ts @@ -18,9 +18,22 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; +import { ConfigDeprecationProvider } from '@kbn/config'; export type KibanaConfigType = TypeOf; +const deprecations: ConfigDeprecationProvider = () => [ + (settings, fromPath, log) => { + const kibana = settings[fromPath]; + if (kibana?.index) { + log( + `"kibana.index" is deprecated. Multitenancy by changing "kibana.index" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details` + ); + } + return settings; + }, +]; + export const config = { path: 'kibana', schema: schema.object({ @@ -29,4 +42,5 @@ export const config = { autocompleteTerminateAfter: schema.duration({ defaultValue: 100000 }), autocompleteTimeout: schema.duration({ defaultValue: 1000 }), }), + deprecations, }; diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 5cc6fcb133507..fe19ef9d0a774 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -362,7 +362,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { REPO_ROOT, getEnvOptions({ cliArgs: { silent: true, basePath: false }, - isDevClusterMaster: true, + isDevCliParent: true, }) ), logger, @@ -391,7 +391,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { REPO_ROOT, getEnvOptions({ cliArgs: { quiet: true, basePath: true }, - isDevClusterMaster: true, + isDevCliParent: true, }) ), logger, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 3111c8daf7981..4ae6c9d437576 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -144,7 +144,7 @@ export class LegacyService implements CoreService { this.log.debug('starting legacy service'); // Receive initial config and create kbnServer/ClusterManager. - if (this.coreContext.env.isDevClusterMaster) { + if (this.coreContext.env.isDevCliParent) { await this.createClusterManager(this.legacyRawConfig!); } else { this.kbnServer = await this.createKbnServer( @@ -310,10 +310,8 @@ export class LegacyService implements CoreService { logger: this.coreContext.logger, }); - // The kbnWorkerType check is necessary to prevent the repl - // from being started multiple times in different processes. - // We only want one REPL. - if (this.coreContext.env.cliArgs.repl && process.env.kbnWorkerType === 'server') { + // Prevent the repl from being started multiple times in different processes. + if (this.coreContext.env.cliArgs.repl && process.env.isDevCliChild) { // eslint-disable-next-line @typescript-eslint/no-var-requires require('./cli').startRepl(kbnServer); } diff --git a/src/core/server/logging/appenders/file/file_appender.ts b/src/core/server/logging/appenders/file/file_appender.ts index c86ea4972324c..2d9ac12148068 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/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 02b82c17ed4fc..601e0038b0cf0 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -102,7 +102,7 @@ const createPlugin = ( }); }; -async function testSetup(options: { isDevClusterMaster?: boolean } = {}) { +async function testSetup(options: { isDevCliParent?: boolean } = {}) { mockPackage.raw = { branch: 'feature-v1', version: 'v1', @@ -116,7 +116,7 @@ async function testSetup(options: { isDevClusterMaster?: boolean } = {}) { coreId = Symbol('core'); env = Env.createDefault(REPO_ROOT, { ...getEnvOptions(), - isDevClusterMaster: options.isDevClusterMaster ?? false, + isDevCliParent: options.isDevCliParent ?? false, }); config$ = new BehaviorSubject>({ plugins: { initialize: true } }); @@ -638,10 +638,10 @@ describe('PluginsService', () => { }); }); -describe('PluginService when isDevClusterMaster is true', () => { +describe('PluginService when isDevCliParent is true', () => { beforeEach(async () => { await testSetup({ - isDevClusterMaster: true, + isDevCliParent: true, }); }); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 5967e6d5358de..e1622b1e19231 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -90,7 +90,7 @@ export class PluginsService implements CoreService(); - const initialize = config.initialize && !this.coreContext.env.isDevClusterMaster; + const initialize = config.initialize && !this.coreContext.env.isDevCliParent; if (initialize) { contracts = await this.pluginsSystem.setupPlugins(deps); this.registerPluginStaticDirs(deps); 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 c26467f4b931c..8f397c01ffa71 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 7965b12eb874e..84b14d0a5f02c 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 8e84f864cf449..8f09e69f6c727 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 a7e689710a564..0070a52fdd1c8 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 5b4fd57e11256..05a91f4aa4c2c 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 d0fcd4b8b66df..07bf320c29496 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 693513dfc7c40..eaa9a42821e48 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 6536406d116d7..83cb2ef75bd55 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/server.test.ts b/src/core/server/server.test.ts index 0c7ebbcb527ec..f377bfc321735 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -216,10 +216,10 @@ test(`doesn't setup core services if legacy config validation fails`, async () = expect(mockI18nService.setup).not.toHaveBeenCalled(); }); -test(`doesn't validate config if env.isDevClusterMaster is true`, async () => { +test(`doesn't validate config if env.isDevCliParent is true`, async () => { const devParentEnv = Env.createDefault(REPO_ROOT, { ...getEnvOptions(), - isDevClusterMaster: true, + isDevCliParent: true, }); const server = new Server(rawConfigService, devParentEnv, logger); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 0f7e8cced999c..e253663d8dc8d 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -124,7 +124,7 @@ export class Server { const legacyConfigSetup = await this.legacy.setupLegacyConfig(); // rely on dev server to validate config, don't validate in the parent process - if (!this.env.isDevClusterMaster) { + if (!this.env.isDevCliParent) { // Immediately terminate in case of invalid configuration // This needs to be done after plugin discovery await this.configService.validate(); diff --git a/src/core/server/utils/index.ts b/src/core/server/utils/index.ts index d9c4217c4117f..b01a4c4e04899 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 447d1ed5b1c53..0000000000000 --- 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/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index 93a173cdbdece..3161420b94d22 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -70,7 +70,6 @@ export function createRootWithSettings( configs: [], cliArgs: { dev: false, - open: false, quiet: false, silent: false, watch: false, @@ -83,7 +82,7 @@ export function createRootWithSettings( dist: false, ...cliArgs, }, - isDevClusterMaster: false, + isDevCliParent: false, }); return new Root( diff --git a/src/dev/build/lib/watch_stdio_for_line.ts b/src/dev/build/lib/watch_stdio_for_line.ts index c97b1c3b26db5..38e0a93ae131f 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/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index 4c833f5be6c5b..3e440c89b82d8 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -166,6 +166,9 @@ kibana_vars=( xpack.code.security.gitProtocolWhitelist xpack.encryptedSavedObjects.encryptionKey xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys + xpack.fleet.agents.elasticsearch.host + xpack.fleet.agents.kibana.host + xpack.fleet.agents.tlsCheckDisabled xpack.graph.enabled xpack.graph.canEditDrillDownUrls xpack.graph.savePolicy 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 c3011fa80988c..b6eda2dbfd560 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/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index aabc1e75b9025..61f578ba33971 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -14,7 +14,7 @@ echo " -- TEST_ES_SNAPSHOT_VERSION='$TEST_ES_SNAPSHOT_VERSION'" ### install dependencies ### echo " -- installing node.js dependencies" -yarn kbn bootstrap --prefer-offline +yarn kbn bootstrap ### ### Download es snapshots diff --git a/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh b/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh index 098737eb2f800..01003b6dc880c 100644 --- a/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh +++ b/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh @@ -8,8 +8,8 @@ PWD=$(pwd) du -sh $COMBINED_EXRACT_DIR echo "### Jest: replacing path in json files" -for i in coverage-final xpack-coverage-final; do - sed -i "s|/dev/shm/workspace/kibana|${PWD}|g" $COMBINED_EXRACT_DIR/jest/${i}.json & +for i in oss oss-integration xpack; do + sed -i "s|/dev/shm/workspace/kibana|${PWD}|g" $COMBINED_EXRACT_DIR/jest/${i}-coverage-final.json & done wait diff --git a/src/legacy/server/kbn_server.js b/src/legacy/server/kbn_server.js index b61a86326ca1a..85d75b4e18772 100644 --- a/src/legacy/server/kbn_server.js +++ b/src/legacy/server/kbn_server.js @@ -20,7 +20,6 @@ import { constant, once, compact, flatten } from 'lodash'; import { reconfigureLogging } from '@kbn/legacy-logging'; -import { isWorker } from 'cluster'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { fromRoot, pkg } from '../../core/server/utils'; import { Config } from './config'; @@ -121,7 +120,7 @@ export default class KbnServer { const { server, config } = this; - if (isWorker) { + if (process.env.isDevCliChild) { // help parent process know when we are ready process.send(['WORKER_LISTENING']); } 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 e9fa2833c3db5..ad938d339f681 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 da6c940c48d0a..3498f205b3286 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 @@ -19,7 +19,7 @@ import { createStreamingBatchedFunction } from './create_streaming_batched_function'; import { fetchStreaming as fetchStreamingReal } from '../streaming/fetch_streaming'; -import { defer, of } from '../../../kibana_utils/public'; +import { AbortError, defer, of } from '../../../kibana_utils/public'; import { Subject } from 'rxjs'; const getPromiseState = (promise: Promise): Promise<'resolved' | 'rejected' | 'pending'> => @@ -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 => @@ -168,6 +168,28 @@ describe('createStreamingBatchedFunction()', () => { expect(fetchStreaming).toHaveBeenCalledTimes(1); }); + test('ignores a request with an aborted signal', async () => { + const { fetchStreaming } = setup(); + const fn = createStreamingBatchedFunction({ + url: '/test', + fetchStreaming, + maxItemAge: 5, + flushOnMaxItems: 3, + }); + + const abortController = new AbortController(); + abortController.abort(); + + of(fn({ foo: 'bar' }, abortController.signal)); + fn({ baz: 'quix' }); + + await new Promise((r) => setTimeout(r, 6)); + const { body } = fetchStreaming.mock.calls[0][0]; + expect(JSON.parse(body)).toEqual({ + batch: [{ baz: 'quix' }], + }); + }); + test('sends POST request to correct endpoint with items in array batched sorted in call order', async () => { const { fetchStreaming } = setup(); const fn = createStreamingBatchedFunction({ @@ -423,6 +445,73 @@ describe('createStreamingBatchedFunction()', () => { expect(result3).toEqual({ b: '3' }); }); + describe('when requests are aborted', () => { + test('aborts stream when all are aborted', async () => { + const { fetchStreaming } = setup(); + const fn = createStreamingBatchedFunction({ + url: '/test', + fetchStreaming, + maxItemAge: 5, + flushOnMaxItems: 3, + }); + + const abortController = new AbortController(); + const promise = fn({ a: '1' }, abortController.signal); + const promise2 = fn({ a: '2' }, abortController.signal); + await new Promise((r) => setTimeout(r, 6)); + + expect(await isPending(promise)).toBe(true); + expect(await isPending(promise2)).toBe(true); + + abortController.abort(); + await new Promise((r) => setTimeout(r, 6)); + + expect(await isPending(promise)).toBe(false); + expect(await isPending(promise2)).toBe(false); + const [, error] = await of(promise); + const [, error2] = await of(promise2); + expect(error).toBeInstanceOf(AbortError); + expect(error2).toBeInstanceOf(AbortError); + expect(fetchStreaming.mock.calls[0][0].signal.aborted).toBeTruthy(); + }); + + test('rejects promise on abort and lets others continue', async () => { + const { fetchStreaming, stream } = setup(); + const fn = createStreamingBatchedFunction({ + url: '/test', + fetchStreaming, + maxItemAge: 5, + flushOnMaxItems: 3, + }); + + const abortController = new AbortController(); + const promise = fn({ a: '1' }, abortController.signal); + const promise2 = fn({ a: '2' }); + await new Promise((r) => setTimeout(r, 6)); + + expect(await isPending(promise)).toBe(true); + + abortController.abort(); + await new Promise((r) => setTimeout(r, 6)); + + expect(await isPending(promise)).toBe(false); + const [, error] = await of(promise); + expect(error).toBeInstanceOf(AbortError); + + stream.next( + JSON.stringify({ + id: 1, + result: { b: '2' }, + }) + '\n' + ); + + await new Promise((r) => setTimeout(r, 1)); + + const [result2] = await of(promise2); + expect(result2).toEqual({ b: '2' }); + }); + }); + describe('when stream closes prematurely', () => { test('rejects pending promises with CONNECTION error code', async () => { const { fetchStreaming, stream } = setup(); @@ -558,5 +647,41 @@ describe('createStreamingBatchedFunction()', () => { }); }); }); + + test('rejects with STREAM error on JSON parse error only pending promises', async () => { + const { fetchStreaming, stream } = setup(); + const fn = createStreamingBatchedFunction({ + url: '/test', + fetchStreaming, + maxItemAge: 5, + flushOnMaxItems: 3, + }); + + const promise1 = of(fn({ a: '1' })); + const promise2 = of(fn({ a: '2' })); + + await new Promise((r) => setTimeout(r, 6)); + + stream.next( + JSON.stringify({ + id: 1, + result: { b: '1' }, + }) + '\n' + ); + + stream.next('Not a JSON\n'); + + await new Promise((r) => setTimeout(r, 1)); + + const [, error1] = await promise1; + const [result1] = await promise2; + expect(error1).toMatchObject({ + message: 'Unexpected token N in JSON at position 0', + code: 'STREAM', + }); + expect(result1).toMatchObject({ + b: '1', + }); + }); }); }); diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts index 89793fff6b325..f3971ed04efa7 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts @@ -17,7 +17,7 @@ * under the License. */ -import { defer, Defer } from '../../../kibana_utils/public'; +import { AbortError, abortSignalToPromise, defer } from '../../../kibana_utils/public'; import { ItemBufferParams, TimedItemBufferParams, @@ -27,13 +27,7 @@ import { } from '../../common'; import { fetchStreaming, split } from '../streaming'; import { normalizeError } from '../../common'; - -export interface BatchItem { - payload: Payload; - future: Defer; -} - -export type BatchedFunc = (payload: Payload) => Promise; +import { BatchedFunc, BatchItem } from './types'; export interface BatchedFunctionProtocolError extends ErrorLike { code: string; @@ -82,43 +76,84 @@ export const createStreamingBatchedFunction = ( flushOnMaxItems = 25, maxItemAge = 10, } = params; - const [fn] = createBatchedFunction, BatchItem>({ - onCall: (payload: Payload) => { + const [fn] = createBatchedFunction({ + onCall: (payload: Payload, signal?: AbortSignal) => { const future = defer(); const entry: BatchItem = { payload, future, + signal, }; return [future.promise, entry]; }, onBatch: async (items) => { try { - let responsesReceived = 0; - const batch = items.map(({ payload }) => payload); + // Filter out any items whose signal is already aborted + items = items.filter((item) => { + if (item.signal?.aborted) item.future.reject(new AbortError()); + return !item.signal?.aborted; + }); + + const donePromises: Array> = items.map((item) => { + return new Promise((resolve) => { + const { promise: abortPromise, cleanup } = item.signal + ? abortSignalToPromise(item.signal) + : { + promise: undefined, + cleanup: () => {}, + }; + + const onDone = () => { + resolve(); + cleanup(); + }; + if (abortPromise) + abortPromise.catch(() => { + item.future.reject(new AbortError()); + onDone(); + }); + item.future.promise.then(onDone, onDone); + }); + }); + + // abort when all items were either resolved, rejected or aborted + const abortController = new AbortController(); + let isBatchDone = false; + Promise.all(donePromises).then(() => { + isBatchDone = true; + abortController.abort(); + }); + const batch = items.map((item) => item.payload); + const { stream } = fetchStreamingInjected({ url, body: JSON.stringify({ batch }), method: 'POST', + signal: abortController.signal, }); + + const handleStreamError = (error: any) => { + const normalizedError = normalizeError(error); + normalizedError.code = 'STREAM'; + for (const { future } of items) future.reject(normalizedError); + }; + stream.pipe(split('\n')).subscribe({ next: (json: string) => { - const response = JSON.parse(json) as BatchResponseItem; - if (response.error) { - responsesReceived++; - items[response.id].future.reject(response.error); - } else if (response.result !== undefined) { - responsesReceived++; - items[response.id].future.resolve(response.result); + try { + const response = JSON.parse(json) as BatchResponseItem; + if (response.error) { + items[response.id].future.reject(response.error); + } else if (response.result !== undefined) { + items[response.id].future.resolve(response.result); + } + } catch (e) { + handleStreamError(e); } }, - error: (error) => { - const normalizedError = normalizeError(error); - normalizedError.code = 'STREAM'; - for (const { future } of items) future.reject(normalizedError); - }, + error: handleStreamError, complete: () => { - const streamTerminatedPrematurely = responsesReceived !== items.length; - if (streamTerminatedPrematurely) { + if (!isBatchDone) { const error: BatchedFunctionProtocolError = { message: 'Connection terminated prematurely.', code: 'CONNECTION', diff --git a/packages/kbn-es-archiver/src/lib/streams/filter_stream.ts b/src/plugins/bfetch/public/batching/types.ts similarity index 71% rename from packages/kbn-es-archiver/src/lib/streams/filter_stream.ts rename to src/plugins/bfetch/public/batching/types.ts index 738b9d5793d06..68860c5d9eedf 100644 --- a/packages/kbn-es-archiver/src/lib/streams/filter_stream.ts +++ b/src/plugins/bfetch/public/batching/types.ts @@ -17,17 +17,15 @@ * under the License. */ -import { Transform } from 'stream'; +import { Defer } from '../../../kibana_utils/public'; -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(); - }, - }); +export interface BatchItem { + payload: Payload; + future: Defer; + signal?: AbortSignal; } + +export type BatchedFunc = ( + payload: Payload, + signal?: AbortSignal +) => Promise; diff --git a/src/plugins/bfetch/public/index.ts b/src/plugins/bfetch/public/index.ts index 8707e5a438159..7ff110105faa0 100644 --- a/src/plugins/bfetch/public/index.ts +++ b/src/plugins/bfetch/public/index.ts @@ -23,6 +23,8 @@ import { BfetchPublicPlugin } from './plugin'; export { BfetchPublicSetup, BfetchPublicStart, BfetchPublicContract } from './plugin'; export { split } from './streaming'; +export { BatchedFunc } from './batching/types'; + export function plugin(initializerContext: PluginInitializerContext) { return new BfetchPublicPlugin(initializerContext); } diff --git a/src/plugins/bfetch/public/plugin.ts b/src/plugins/bfetch/public/plugin.ts index 5f01957c0908e..72aaa862b0ad2 100644 --- a/src/plugins/bfetch/public/plugin.ts +++ b/src/plugins/bfetch/public/plugin.ts @@ -22,9 +22,9 @@ import { fetchStreaming as fetchStreamingStatic, FetchStreamingParams } from './ import { removeLeadingSlash } from '../common'; import { createStreamingBatchedFunction, - BatchedFunc, StreamingBatchedFunctionParams, } from './batching/create_streaming_batched_function'; +import { BatchedFunc } from './batching/types'; // eslint-disable-next-line export interface BfetchPublicSetupDependencies {} diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts index 27adc6dc8b549..7a6827b8fee8e 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts @@ -132,6 +132,33 @@ test('completes stream observable when request finishes', async () => { expect(spy).toHaveBeenCalledTimes(1); }); +test('completes stream observable when aborted', async () => { + const env = setup(); + const abort = new AbortController(); + const { stream } = fetchStreaming({ + url: 'http://example.com', + signal: abort.signal, + }); + + const spy = jest.fn(); + stream.subscribe({ + complete: spy, + }); + + expect(spy).toHaveBeenCalledTimes(0); + + (env.xhr as any).responseText = 'foo'; + env.xhr.onprogress!({} as any); + + abort.abort(); + + (env.xhr as any).readyState = 4; + (env.xhr as any).status = 200; + env.xhr.onreadystatechange!({} as any); + + expect(spy).toHaveBeenCalledTimes(1); +}); + test('promise throws when request errors', async () => { const env = setup(); const { stream } = fetchStreaming({ diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.ts index 899e8a1824a41..3deee0cf66add 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.ts @@ -24,6 +24,7 @@ export interface FetchStreamingParams { headers?: Record; method?: 'GET' | 'POST'; body?: string; + signal?: AbortSignal; } /** @@ -35,6 +36,7 @@ export function fetchStreaming({ headers = {}, method = 'POST', body = '', + signal, }: FetchStreamingParams) { const xhr = new window.XMLHttpRequest(); @@ -45,7 +47,7 @@ export function fetchStreaming({ // Set the HTTP headers Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); - const stream = fromStreamingXhr(xhr); + const stream = fromStreamingXhr(xhr, signal); // Send the payload to the server xhr.send(body); diff --git a/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts b/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts index 40eb3d5e2556b..b15bf9bdfbbb0 100644 --- a/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts +++ b/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts @@ -21,6 +21,7 @@ import { fromStreamingXhr } from './from_streaming_xhr'; const createXhr = (): XMLHttpRequest => (({ + abort: () => {}, onprogress: () => {}, onreadystatechange: () => {}, readyState: 0, @@ -100,6 +101,39 @@ test('completes observable when request reaches end state', () => { expect(complete).toHaveBeenCalledTimes(1); }); +test('completes observable when aborted', () => { + const xhr = createXhr(); + const abortController = new AbortController(); + const observable = fromStreamingXhr(xhr, abortController.signal); + + const next = jest.fn(); + const complete = jest.fn(); + observable.subscribe({ + next, + complete, + }); + + (xhr as any).responseText = '1'; + xhr.onprogress!({} as any); + + (xhr as any).responseText = '2'; + xhr.onprogress!({} as any); + + expect(complete).toHaveBeenCalledTimes(0); + + (xhr as any).readyState = 2; + abortController.abort(); + + expect(complete).toHaveBeenCalledTimes(1); + + // Shouldn't trigger additional events + (xhr as any).readyState = 4; + (xhr as any).status = 200; + xhr.onreadystatechange!({} as any); + + expect(complete).toHaveBeenCalledTimes(1); +}); + test('errors observable if request returns with error', () => { const xhr = createXhr(); const observable = fromStreamingXhr(xhr); diff --git a/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts b/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts index bba8151958492..5df1f5258cb2d 100644 --- a/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts +++ b/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts @@ -26,13 +26,17 @@ import { Observable, Subject } from 'rxjs'; export const fromStreamingXhr = ( xhr: Pick< XMLHttpRequest, - 'onprogress' | 'onreadystatechange' | 'readyState' | 'status' | 'responseText' - > + 'onprogress' | 'onreadystatechange' | 'readyState' | 'status' | 'responseText' | 'abort' + >, + signal?: AbortSignal ): Observable => { const subject = new Subject(); let index = 0; + let aborted = false; const processBatch = () => { + if (aborted) return; + const { responseText } = xhr; if (index >= responseText.length) return; subject.next(responseText.substr(index)); @@ -41,7 +45,19 @@ export const fromStreamingXhr = ( xhr.onprogress = processBatch; + const onBatchAbort = () => { + if (xhr.readyState !== 4) { + aborted = true; + xhr.abort(); + subject.complete(); + if (signal) signal.removeEventListener('abort', onBatchAbort); + } + }; + + if (signal) signal.addEventListener('abort', onBatchAbort); + xhr.onreadystatechange = () => { + if (aborted) return; // Older browsers don't support onprogress, so we need // to call this here, too. It's safe to call this multiple // times even for the same progress event. @@ -49,6 +65,8 @@ export const fromStreamingXhr = ( // 4 is the magic number that means the request is done if (xhr.readyState === 4) { + if (signal) signal.removeEventListener('abort', onBatchAbort); + // 0 indicates a network failure. 400+ messages are considered server errors if (xhr.status === 0 || xhr.status >= 400) { subject.error(new Error(`Batch request failed with status ${xhr.status}`)); 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 84b12c97f1856..34f43886df66e 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 27e19d920ad17..b6d7fc97f49a8 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/add_to_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx index feb30b248c066..5f3945e733527 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx @@ -137,12 +137,17 @@ test('Add to library is not compatible when embeddable is not in a dashboard con test('Add to library replaces embeddableId and retains panel count', async () => { const dashboard = embeddable.getRoot() as IContainer; const originalPanelCount = Object.keys(dashboard.getInput().panels).length; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); - expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); - const newPanel = container.getInput().panels[embeddable.id!]; + + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + expect(newPanelId).toBeDefined(); + const newPanel = container.getInput().panels[newPanelId!]; expect(newPanel.type).toEqual(embeddable.type); }); @@ -158,10 +163,15 @@ test('Add to library returns reference type input', async () => { mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id } as EmbeddableInput, }); + const dashboard = embeddable.getRoot() as IContainer; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); - expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); - const newPanel = container.getInput().panels[embeddable.id!]; + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + expect(newPanelId).toBeDefined(); + const newPanel = container.getInput().panels[newPanelId!]; expect(newPanel.type).toEqual(embeddable.type); expect(newPanel.explicitInput.attributes).toBeUndefined(); expect(newPanel.explicitInput.savedObjectId).toBe('testSavedObjectId'); diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx index 179e5d522a2b3..08cd0c7a15381 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx @@ -19,7 +19,6 @@ import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import uuid from 'uuid'; import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; import { @@ -89,9 +88,9 @@ export class AddToLibraryAction implements ActionByType = { type: embeddable.type, - explicitInput: { ...newInput, id: uuid.v4() }, + explicitInput: { ...newInput }, }; - dashboard.replacePanel(panelToReplace, newPanel); + dashboard.replacePanel(panelToReplace, newPanel, true); const title = i18n.translate('dashboard.panel.addToLibrary.successMessage', { defaultMessage: `Panel '{panelTitle}' was added to the visualize library`, 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 933d2766d13f4..dcce38cdf94ce 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/dashboard/public/application/actions/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx index f191be6f7baad..6a9769b0c8d16 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx @@ -135,11 +135,16 @@ test('Unlink is not compatible when embeddable is not in a dashboard container', test('Unlink replaces embeddableId and retains panel count', async () => { const dashboard = embeddable.getRoot() as IContainer; const originalPanelCount = Object.keys(dashboard.getInput().panels).length; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); - expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); - const newPanel = container.getInput().panels[embeddable.id!]; + + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + expect(newPanelId).toBeDefined(); + const newPanel = container.getInput().panels[newPanelId!]; expect(newPanel.type).toEqual(embeddable.type); }); @@ -159,10 +164,15 @@ test('Unlink unwraps all attributes from savedObject', async () => { mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id }, }); + const dashboard = embeddable.getRoot() as IContainer; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); - expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); - const newPanel = container.getInput().panels[embeddable.id!]; + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + expect(newPanelId).toBeDefined(); + const newPanel = container.getInput().panels[newPanelId!]; expect(newPanel.type).toEqual(embeddable.type); expect(newPanel.explicitInput.attributes).toEqual(complicatedAttributes); }); diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx index 5e16145364712..b20bbc6350aaa 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -19,7 +19,6 @@ import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import uuid from 'uuid'; import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; import { @@ -88,9 +87,9 @@ export class UnlinkFromLibraryAction implements ActionByType = { type: embeddable.type, - explicitInput: { ...newInput, id: uuid.v4() }, + explicitInput: { ...newInput }, }; - dashboard.replacePanel(panelToReplace, newPanel); + dashboard.replacePanel(panelToReplace, newPanel, true); const title = embeddable.getTitle() ? i18n.translate('dashboard.panel.unlinkFromLibrary.successMessageWithTitle', { diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 051a7ef8bfb92..e80d387fa3066 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -173,11 +173,30 @@ export class DashboardContainer extends Container, - newPanelState: Partial + newPanelState: Partial, + generateNewId?: boolean ) { - // Because the embeddable type can change, we have to operate at the container level here - return this.updateInput({ - panels: { + let panels; + if (generateNewId) { + // replace panel can be called with generateNewId in order to totally destroy and recreate the embeddable + panels = { ...this.input.panels }; + delete panels[previousPanelState.explicitInput.id]; + const newId = uuid.v4(); + panels[newId] = { + ...previousPanelState, + ...newPanelState, + gridData: { + ...previousPanelState.gridData, + i: newId, + }, + explicitInput: { + ...newPanelState.explicitInput, + id: newId, + }, + }; + } else { + // Because the embeddable type can change, we have to operate at the container level here + panels = { ...this.input.panels, [previousPanelState.explicitInput.id]: { ...previousPanelState, @@ -190,7 +209,11 @@ export class DashboardContainer extends Container `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 0000000000000..1e1420c245eb4 --- /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 f13c869b563a2..72faac654b421 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 2d6637daf4324..36129a4d3f8cd 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 b16242e519872..04a748bfb1965 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/common/search/es_search/es_search_rxjs_utils.ts b/src/plugins/data/common/search/es_search/es_search_rxjs_utils.ts deleted file mode 100644 index e3238ea62db57..0000000000000 --- a/src/plugins/data/common/search/es_search/es_search_rxjs_utils.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { from } from 'rxjs'; -import { map } from 'rxjs/operators'; - -import type { SearchResponse } from 'elasticsearch'; -import type { ApiResponse } from '@elastic/elasticsearch'; - -import { shimAbortSignal } from './shim_abort_signal'; -import { getTotalLoaded } from './get_total_loaded'; - -import type { IEsRawSearchResponse } from './types'; -import type { IKibanaSearchResponse } from '../types'; - -export const doSearch = ( - searchMethod: () => Promise, - abortSignal?: AbortSignal -) => from(shimAbortSignal(searchMethod(), abortSignal)); - -export const toKibanaSearchResponse = < - SearchResponse extends IEsRawSearchResponse = IEsRawSearchResponse, - KibanaResponse extends IKibanaSearchResponse = IKibanaSearchResponse ->() => - map, KibanaResponse>( - (response) => - ({ - id: response.body.id, - isPartial: response.body.is_partial || false, - isRunning: response.body.is_running || false, - rawResponse: response.body, - } as KibanaResponse) - ); - -export const includeTotalLoaded = () => - map((response: IKibanaSearchResponse>) => ({ - ...response, - ...getTotalLoaded(response.rawResponse._shards), - })); diff --git a/src/plugins/data/common/search/es_search/get_total_loaded.test.ts b/src/plugins/data/common/search/es_search/get_total_loaded.test.ts deleted file mode 100644 index 74e2873ede762..0000000000000 --- a/src/plugins/data/common/search/es_search/get_total_loaded.test.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 { getTotalLoaded } from './get_total_loaded'; - -describe('getTotalLoaded', () => { - it('returns the total/loaded, not including skipped', () => { - const result = getTotalLoaded({ - successful: 10, - failed: 5, - skipped: 5, - total: 100, - }); - - expect(result).toEqual({ - total: 100, - loaded: 15, - }); - }); -}); diff --git a/src/plugins/data/common/search/es_search/index.ts b/src/plugins/data/common/search/es_search/index.ts index 555667a9f5300..d8f7b5091eb8f 100644 --- a/src/plugins/data/common/search/es_search/index.ts +++ b/src/plugins/data/common/search/es_search/index.ts @@ -18,8 +18,3 @@ */ export * from './types'; -export * from './utils'; -export * from './es_search_rxjs_utils'; -export * from './shim_abort_signal'; -export * from './to_snake_case'; -export * from './get_total_loaded'; diff --git a/src/plugins/data/common/search/es_search/shim_abort_signal.test.ts b/src/plugins/data/common/search/es_search/shim_abort_signal.test.ts deleted file mode 100644 index 61af8b4c782ae..0000000000000 --- a/src/plugins/data/common/search/es_search/shim_abort_signal.test.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. - */ - -import { shimAbortSignal } from './shim_abort_signal'; - -const createSuccessTransportRequestPromise = ( - body: any, - { statusCode = 200 }: { statusCode?: number } = {} -) => { - const promise = Promise.resolve({ body, statusCode }) as any; - promise.abort = jest.fn(); - - return promise; -}; - -describe('shimAbortSignal', () => { - test('aborts the promise if the signal is aborted', () => { - const promise = createSuccessTransportRequestPromise({ - success: true, - }); - const controller = new AbortController(); - shimAbortSignal(promise, controller.signal); - controller.abort(); - - expect(promise.abort).toHaveBeenCalled(); - }); - - test('returns the original promise', async () => { - const promise = createSuccessTransportRequestPromise({ - success: true, - }); - const controller = new AbortController(); - const response = await shimAbortSignal(promise, controller.signal); - - expect(response).toEqual(expect.objectContaining({ body: { success: true } })); - }); - - test('allows the promise to be aborted manually', () => { - const promise = createSuccessTransportRequestPromise({ - success: true, - }); - const controller = new AbortController(); - const enhancedPromise = shimAbortSignal(promise, controller.signal); - - enhancedPromise.abort(); - expect(promise.abort).toHaveBeenCalled(); - }); -}); diff --git a/src/plugins/data/common/search/es_search/shim_abort_signal.ts b/src/plugins/data/common/search/es_search/shim_abort_signal.ts deleted file mode 100644 index 554a24e268815..0000000000000 --- a/src/plugins/data/common/search/es_search/shim_abort_signal.ts +++ /dev/null @@ -1,48 +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. - */ - -/** - * @internal - * TransportRequestPromise extends base Promise with an "abort" method - */ -export interface TransportRequestPromise extends Promise { - abort?: () => void; -} - -/** - * - * @internal - * NOTE: Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 - * is resolved - * - * @param promise a TransportRequestPromise - * @param signal optional AbortSignal - * - * @returns a TransportRequestPromise that will be aborted if the signal is aborted - */ - -export const shimAbortSignal = >( - promise: T, - signal: AbortSignal | undefined -): T => { - if (signal) { - signal.addEventListener('abort', () => promise.abort && promise.abort()); - } - return promise; -}; diff --git a/src/plugins/data/common/search/es_search/types.ts b/src/plugins/data/common/search/es_search/types.ts index 7d81cf42e1866..7dbbd01d2cdad 100644 --- a/src/plugins/data/common/search/es_search/types.ts +++ b/src/plugins/data/common/search/es_search/types.ts @@ -30,10 +30,4 @@ export interface IEsSearchRequest extends IKibanaSearchRequest extends SearchResponse { - id?: string; - is_partial?: boolean; - is_running?: boolean; -} - export type IEsSearchResponse = IKibanaSearchResponse>; diff --git a/src/plugins/data/common/search/index.ts b/src/plugins/data/common/search/index.ts index e650cf10db87c..01944d6e37aaf 100644 --- a/src/plugins/data/common/search/index.ts +++ b/src/plugins/data/common/search/index.ts @@ -24,3 +24,4 @@ export * from './search_source'; export * from './tabify'; export * from './types'; export * from './session'; +export * from './utils'; diff --git a/src/plugins/data/common/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts index ea7d6b4441ccf..dd2b0eaccc86e 100644 --- a/src/plugins/data/common/search/search_source/mocks.ts +++ b/src/plugins/data/common/search/search_source/mocks.ts @@ -28,6 +28,7 @@ export const searchSourceInstanceMock: MockedKeys = { setPreferredSearchStrategyId: jest.fn(), setFields: jest.fn().mockReturnThis(), setField: jest.fn().mockReturnThis(), + removeField: jest.fn().mockReturnThis(), getId: jest.fn(), getFields: jest.fn(), getField: jest.fn(), diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 98d66310c040e..e7bdcb159f3cb 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -82,6 +82,15 @@ describe('SearchSource', () => { }); }); + describe('#removeField()', () => { + test('remove property', () => { + const searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('aggs', 5); + searchSource.removeField('aggs'); + expect(searchSource.getField('aggs')).toBeFalsy(); + }); + }); + describe(`#setField('index')`, () => { describe('auto-sourceFiltering', () => { describe('new index pattern assigned', () => { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 9bc65ca341980..79ef3a3f11ca5 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -142,10 +142,18 @@ export class SearchSource { */ setField(field: K, value: SearchSourceFields[K]) { if (value == null) { - delete this.fields[field]; - } else { - this.fields[field] = value; + return this.removeField(field); } + this.fields[field] = value; + return this; + } + + /** + * remove field + * @param field: field name + */ + removeField(field: K) { + delete this.fields[field]; return this; } diff --git a/src/plugins/data/common/search/utils.test.ts b/src/plugins/data/common/search/utils.test.ts new file mode 100644 index 0000000000000..94f7b14de4bc3 --- /dev/null +++ b/src/plugins/data/common/search/utils.test.ts @@ -0,0 +1,106 @@ +/* + * 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 { isErrorResponse, isCompleteResponse, isPartialResponse } from './utils'; + +describe('utils', () => { + describe('isErrorResponse', () => { + it('returns `true` if the response is undefined', () => { + const isError = isErrorResponse(); + expect(isError).toBe(true); + }); + + it('returns `true` if the response is not running and partial', () => { + const isError = isErrorResponse({ + isPartial: true, + isRunning: false, + rawResponse: {}, + }); + expect(isError).toBe(true); + }); + + it('returns `false` if the response is running and partial', () => { + const isError = isErrorResponse({ + isPartial: true, + isRunning: true, + rawResponse: {}, + }); + expect(isError).toBe(false); + }); + + it('returns `false` if the response is complete', () => { + const isError = isErrorResponse({ + isPartial: false, + isRunning: false, + rawResponse: {}, + }); + expect(isError).toBe(false); + }); + }); + + describe('isCompleteResponse', () => { + it('returns `false` if the response is undefined', () => { + const isError = isCompleteResponse(); + expect(isError).toBe(false); + }); + + it('returns `false` if the response is running and partial', () => { + const isError = isCompleteResponse({ + isPartial: true, + isRunning: true, + rawResponse: {}, + }); + expect(isError).toBe(false); + }); + + it('returns `true` if the response is complete', () => { + const isError = isCompleteResponse({ + isPartial: false, + isRunning: false, + rawResponse: {}, + }); + expect(isError).toBe(true); + }); + }); + + describe('isPartialResponse', () => { + it('returns `false` if the response is undefined', () => { + const isError = isPartialResponse(); + expect(isError).toBe(false); + }); + + it('returns `true` if the response is running and partial', () => { + const isError = isPartialResponse({ + isPartial: true, + isRunning: true, + rawResponse: {}, + }); + expect(isError).toBe(true); + }); + + it('returns `false` if the response is complete', () => { + const isError = isPartialResponse({ + isPartial: false, + isRunning: false, + rawResponse: {}, + }); + expect(isError).toBe(false); + }); + }); +}); diff --git a/src/plugins/data/common/search/es_search/utils.ts b/src/plugins/data/common/search/utils.ts similarity index 96% rename from src/plugins/data/common/search/es_search/utils.ts rename to src/plugins/data/common/search/utils.ts index 6ed222ab0830c..0d544a51c2d45 100644 --- a/src/plugins/data/common/search/es_search/utils.ts +++ b/src/plugins/data/common/search/utils.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { IKibanaSearchResponse } from '../types'; +import type { IKibanaSearchResponse } from './types'; /** * @returns true if response had an error while executing in ES diff --git a/src/plugins/data/kibana.json b/src/plugins/data/kibana.json index d6f2534bd5e3b..3e4d08c8faa1b 100644 --- a/src/plugins/data/kibana.json +++ b/src/plugins/data/kibana.json @@ -4,6 +4,7 @@ "server": true, "ui": true, "requiredPlugins": [ + "bfetch", "expressions", "uiActions" ], diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 129addf3de70e..e0b0c5a0ea980 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/plugin.ts b/src/plugins/data/public/plugin.ts index 7e8283476ffc5..dded52310a99c 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -105,7 +105,7 @@ export class DataPublicPlugin public setup( core: CoreSetup, - { expressions, uiActions, usageCollection }: DataSetupDependencies + { bfetch, expressions, uiActions, usageCollection }: DataSetupDependencies ): DataPublicPluginSetup { const startServices = createStartServicesGetter(core.getStartServices); @@ -152,6 +152,7 @@ export class DataPublicPlugin ); const searchService = this.searchService.setup(core, { + bfetch, usageCollection, expressions, }); diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 5a707393b39f4..a6daaf834a424 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -12,12 +12,14 @@ import { ApiResponse as ApiResponse_2 } from '@elastic/elasticsearch/lib/Transpo import { ApplicationStart } from 'kibana/public'; import { Assign } from '@kbn/utility-types'; import { BehaviorSubject } from 'rxjs'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; import Boom from '@hapi/boom'; 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 +37,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 +675,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) @@ -1676,7 +1687,7 @@ export interface OptionedValueProp { // @public (undocumented) export class PainlessError extends EsError { // Warning: (ae-forgotten-export) The symbol "IEsError" needs to be exported by the entry point index.d.ts - constructor(err: IEsError, request: IKibanaSearchRequest); + constructor(err: IEsError); // (undocumented) getErrorMessage(application: ApplicationStart): JSX.Element; // (undocumented) @@ -1724,7 +1735,7 @@ export class Plugin implements Plugin_2); // (undocumented) - setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; + setup(core: CoreSetup, { bfetch, expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; // (undocumented) start(core: CoreStart_2, { uiActions }: DataStartDependencies): DataPublicPluginStart; // (undocumented) @@ -2037,8 +2048,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 @@ -2080,7 +2091,7 @@ export class SearchInterceptor { // (undocumented) protected getTimeoutMode(): TimeoutErrorMode; // (undocumented) - protected handleSearchError(e: any, request: IKibanaSearchRequest, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; + protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; // @internal protected pendingCount$: BehaviorSubject; // @internal (undocumented) @@ -2103,6 +2114,8 @@ export class SearchInterceptor { // // @public (undocumented) export interface SearchInterceptorDeps { + // (undocumented) + bfetch: BfetchPublicSetup; // (undocumented) http: CoreSetup_2['http']; // (undocumented) @@ -2159,6 +2172,7 @@ export class SearchSource { // (undocumented) history: SearchRequest[]; onRequestStart(handler: (searchSource: SearchSource, options?: ISearchOptions) => Promise): void; + removeField(field: K): this; serialize(): { searchSourceJSON: string; references: import("src/core/server").SavedObjectReference[]; @@ -2392,27 +2406,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/public/search/errors/painless_error.tsx b/src/plugins/data/public/search/errors/painless_error.tsx index 282a602d358c7..3cfe9f4278ba0 100644 --- a/src/plugins/data/public/search/errors/painless_error.tsx +++ b/src/plugins/data/public/search/errors/painless_error.tsx @@ -25,11 +25,10 @@ import { ApplicationStart } from 'kibana/public'; import { IEsError, isEsError } from './types'; import { EsError } from './es_error'; import { getRootCause } from './utils'; -import { IKibanaSearchRequest } from '..'; export class PainlessError extends EsError { painlessStack?: string; - constructor(err: IEsError, request: IKibanaSearchRequest) { + constructor(err: IEsError) { super(err); } diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index 60274261da25f..6dc52d7016797 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -25,9 +25,13 @@ import { AbortError } from '../../../kibana_utils/public'; import { SearchTimeoutError, PainlessError, TimeoutErrorMode } from './errors'; import { searchServiceMock } from './mocks'; import { ISearchStart } from '.'; +import { bfetchPluginMock } from '../../../bfetch/public/mocks'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; let searchInterceptor: SearchInterceptor; let mockCoreSetup: MockedKeys; +let bfetchSetup: jest.Mocked; +let fetchMock: jest.Mock; const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); jest.useFakeTimers(); @@ -39,7 +43,11 @@ describe('SearchInterceptor', () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); searchMock = searchServiceMock.createStartContract(); + fetchMock = jest.fn(); + bfetchSetup = bfetchPluginMock.createSetupContract(); + bfetchSetup.batchedFunction.mockReturnValue(fetchMock); searchInterceptor = new SearchInterceptor({ + bfetch: bfetchSetup, toasts: mockCoreSetup.notifications.toasts, startServices: new Promise((resolve) => { resolve([mockCoreStart, {}, {}]); @@ -65,20 +73,17 @@ describe('SearchInterceptor', () => { test('Renders a PainlessError', async () => { searchInterceptor.showError( - new PainlessError( - { - body: { - attributes: { - error: { - failed_shards: { - reason: 'bananas', - }, + new PainlessError({ + body: { + attributes: { + error: { + failed_shards: { + reason: 'bananas', }, }, - } as any, - }, - {} as any - ) + }, + } as any, + }) ); expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); @@ -94,7 +99,7 @@ describe('SearchInterceptor', () => { describe('search', () => { test('Observable should resolve if fetch is successful', async () => { const mockResponse: any = { result: 200 }; - mockCoreSetup.http.fetch.mockResolvedValueOnce(mockResponse); + fetchMock.mockResolvedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -105,7 +110,7 @@ describe('SearchInterceptor', () => { describe('Should throw typed errors', () => { test('Observable should fail if fetch has an internal error', async () => { const mockResponse: any = new Error('Internal Error'); - mockCoreSetup.http.fetch.mockRejectedValue(mockResponse); + fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -121,7 +126,7 @@ describe('SearchInterceptor', () => { message: 'Request timed out', }, }; - mockCoreSetup.http.fetch.mockRejectedValueOnce(mockResponse); + fetchMock.mockRejectedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -137,7 +142,7 @@ describe('SearchInterceptor', () => { message: 'Request timed out', }, }; - mockCoreSetup.http.fetch.mockRejectedValue(mockResponse); + fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -158,7 +163,7 @@ describe('SearchInterceptor', () => { message: 'Request timed out', }, }; - mockCoreSetup.http.fetch.mockRejectedValue(mockResponse); + fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -179,7 +184,7 @@ describe('SearchInterceptor', () => { message: 'Request timed out', }, }; - mockCoreSetup.http.fetch.mockRejectedValue(mockResponse); + fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -212,7 +217,7 @@ describe('SearchInterceptor', () => { }, }, }; - mockCoreSetup.http.fetch.mockRejectedValueOnce(mockResponse); + fetchMock.mockRejectedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -222,7 +227,7 @@ describe('SearchInterceptor', () => { test('Observable should fail if user aborts (test merged signal)', async () => { const abortController = new AbortController(); - mockCoreSetup.http.fetch.mockImplementationOnce((options: any) => { + fetchMock.mockImplementationOnce((options: any) => { return new Promise((resolve, reject) => { options.signal.addEventListener('abort', () => { reject(new AbortError()); @@ -260,7 +265,7 @@ describe('SearchInterceptor', () => { const error = (e: any) => { expect(e).toBeInstanceOf(AbortError); - expect(mockCoreSetup.http.fetch).not.toBeCalled(); + expect(fetchMock).not.toBeCalled(); done(); }; response.subscribe({ error }); diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index 3fadb723b27cd..055b3a71705bf 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -17,17 +17,17 @@ * under the License. */ -import { get, memoize, trimEnd } from 'lodash'; +import { get, memoize } from 'lodash'; import { BehaviorSubject, throwError, timer, defer, from, Observable, NEVER } from 'rxjs'; import { catchError, finalize } from 'rxjs/operators'; import { PublicMethodsOf } from '@kbn/utility-types'; import { CoreStart, CoreSetup, ToastsSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; +import { BatchedFunc, BfetchPublicSetup } from 'src/plugins/bfetch/public'; import { IKibanaSearchRequest, IKibanaSearchResponse, ISearchOptions, - ES_SEARCH_STRATEGY, ISessionService, } from '../../common'; import { SearchUsageCollector } from './collectors'; @@ -44,6 +44,7 @@ import { toMountPoint } from '../../../kibana_react/public'; import { AbortError, getCombinedAbortSignal } from '../../../kibana_utils/public'; export interface SearchInterceptorDeps { + bfetch: BfetchPublicSetup; http: CoreSetup['http']; uiSettings: CoreSetup['uiSettings']; startServices: Promise<[CoreStart, any, unknown]>; @@ -69,6 +70,10 @@ export class SearchInterceptor { * @internal */ protected application!: CoreStart['application']; + private batchedFetch!: BatchedFunc< + { request: IKibanaSearchRequest; options: ISearchOptions }, + IKibanaSearchResponse + >; /* * @internal @@ -79,6 +84,10 @@ export class SearchInterceptor { this.deps.startServices.then(([coreStart]) => { this.application = coreStart.application; }); + + this.batchedFetch = deps.bfetch.batchedFunction({ + url: '/internal/bsearch', + }); } /* @@ -93,12 +102,7 @@ export class SearchInterceptor { * @returns `Error` a search service specific error or the original error, if a specific error can't be recognized. * @internal */ - protected handleSearchError( - e: any, - request: IKibanaSearchRequest, - timeoutSignal: AbortSignal, - options?: ISearchOptions - ): Error { + protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error { if (timeoutSignal.aborted || get(e, 'body.message') === 'Request timed out') { // Handle a client or a server side timeout const err = new SearchTimeoutError(e, this.getTimeoutMode()); @@ -112,7 +116,7 @@ export class SearchInterceptor { return e; } else if (isEsError(e)) { if (isPainlessError(e)) { - return new PainlessError(e, request); + return new PainlessError(e); } else { return new EsError(e); } @@ -128,24 +132,14 @@ export class SearchInterceptor { request: IKibanaSearchRequest, options?: ISearchOptions ): Promise { - const { id, ...searchRequest } = request; - const path = trimEnd( - `/internal/search/${options?.strategy ?? ES_SEARCH_STRATEGY}/${id ?? ''}`, - '/' + const { abortSignal, ...requestOptions } = options || {}; + return this.batchedFetch( + { + request, + options: requestOptions, + }, + abortSignal ); - const body = JSON.stringify({ - sessionId: options?.sessionId, - isStored: options?.isStored, - isRestore: options?.isRestore, - ...searchRequest, - }); - - return this.deps.http.fetch({ - method: 'POST', - path, - body, - signal: options?.abortSignal, - }); } /** @@ -244,7 +238,7 @@ export class SearchInterceptor { this.pendingCount$.next(this.pendingCount$.getValue() + 1); return from(this.runSearch(request, { ...options, abortSignal: combinedSignal })).pipe( catchError((e: Error) => { - return throwError(this.handleSearchError(e, request, timeoutSignal, options)); + return throwError(this.handleSearchError(e, timeoutSignal, options)); }), finalize(() => { this.pendingCount$.next(this.pendingCount$.getValue() - 1); diff --git a/src/plugins/data/public/search/search_service.test.ts b/src/plugins/data/public/search/search_service.test.ts index 20041a02067d9..3179da4d03a1a 100644 --- a/src/plugins/data/public/search/search_service.test.ts +++ b/src/plugins/data/public/search/search_service.test.ts @@ -21,6 +21,7 @@ import { coreMock } from '../../../../core/public/mocks'; import { CoreSetup, CoreStart } from '../../../../core/public'; import { SearchService, SearchServiceSetupDependencies } from './search_service'; +import { bfetchPluginMock } from '../../../bfetch/public/mocks'; describe('Search service', () => { let searchService: SearchService; @@ -39,8 +40,10 @@ describe('Search service', () => { describe('setup()', () => { it('exposes proper contract', async () => { + const bfetch = bfetchPluginMock.createSetupContract(); const setup = searchService.setup(mockCoreSetup, ({ packageInfo: { version: '8' }, + bfetch, expressions: { registerFunction: jest.fn(), registerType: jest.fn() }, } as unknown) as SearchServiceSetupDependencies); expect(setup).toHaveProperty('aggs'); diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 96fb3f91ea85f..b76b5846d3d93 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -19,6 +19,7 @@ import { Plugin, CoreSetup, CoreStart, PluginInitializerContext } from 'src/core/public'; import { BehaviorSubject } from 'rxjs'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; import { ISearchSetup, ISearchStart, SearchEnhancements } from './types'; import { handleResponse } from './fetch'; @@ -49,6 +50,7 @@ import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn'; /** @internal */ export interface SearchServiceSetupDependencies { + bfetch: BfetchPublicSetup; expressions: ExpressionsSetup; usageCollection?: UsageCollectionSetup; } @@ -70,7 +72,7 @@ export class SearchService implements Plugin { public setup( { http, getStartServices, notifications, uiSettings }: CoreSetup, - { expressions, usageCollection }: SearchServiceSetupDependencies + { bfetch, expressions, usageCollection }: SearchServiceSetupDependencies ): ISearchSetup { this.usageCollector = createUsageCollector(getStartServices, usageCollection); @@ -80,6 +82,7 @@ export class SearchService implements Plugin { * all pending search requests, as well as getting the number of pending search requests. */ this.searchInterceptor = new SearchInterceptor({ + bfetch, toasts: notifications.toasts, http, uiSettings, diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index 21a03a49fe058..4082fbe55094c 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -19,6 +19,7 @@ import React from 'react'; import { CoreStart } from 'src/core/public'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { ExpressionsSetup } from 'src/plugins/expressions/public'; import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; @@ -36,6 +37,7 @@ export interface DataPublicPluginEnhancements { } export interface DataSetupDependencies { + bfetch: BfetchPublicSetup; expressions: ExpressionsSetup; uiActions: UiActionsSetup; usageCollection?: UsageCollectionSetup; diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index e24869f5237ea..a233447cdf438 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: */ @@ -146,7 +156,6 @@ export { IndexPatternAttributes, UI_SETTINGS, IndexPattern, - IEsRawSearchResponse, } from '../common'; /** @@ -179,13 +188,7 @@ import { // tabify tabifyAggResponse, tabifyGetColumns, - // search - toSnakeCase, - shimAbortSignal, - doSearch, - includeTotalLoaded, - toKibanaSearchResponse, - getTotalLoaded, + calcAutoIntervalLessThan, } from '../common'; export { @@ -232,27 +235,17 @@ export { SearchStrategyDependencies, getDefaultSearchParams, getShardTimeout, + getTotalLoaded, + toKibanaSearchResponse, shimHitsTotal, usageProvider, + searchUsageObserver, + shimAbortSignal, SearchUsage, } from './search'; -import { trackSearchStatus } from './search'; - // Search namespace export const search = { - esSearch: { - utils: { - doSearch, - shimAbortSignal, - trackSearchStatus, - includeTotalLoaded, - toKibanaSearchResponse, - // utils: - getTotalLoaded, - toSnakeCase, - }, - }, aggs: { CidrMask, dateHistogramInterval, @@ -272,6 +265,7 @@ export const search = { siblingPipelineType, termsAggFilter, toAbsoluteDates, + calcAutoIntervalLessThan, }, getRequestInspectorStats, getResponseInspectorStats, diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index 3ec4e7e64e382..bba2c368ff7d1 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -19,6 +19,7 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from 'src/core/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; +import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { ConfigSchema } from '../config'; import { IndexPatternsService, IndexPatternsServiceStart } from './index_patterns'; import { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; @@ -51,6 +52,7 @@ export interface DataPluginStart { } export interface DataPluginSetupDependencies { + bfetch: BfetchServerSetup; expressions: ExpressionsServerSetup; usageCollection?: UsageCollectionSetup; } @@ -85,7 +87,7 @@ export class DataServerPlugin public setup( core: CoreSetup, - { expressions, usageCollection }: DataPluginSetupDependencies + { bfetch, expressions, usageCollection }: DataPluginSetupDependencies ) { this.indexPatterns.setup(core); this.scriptsService.setup(core); @@ -96,6 +98,7 @@ export class DataServerPlugin core.uiSettings.register(getUiSettings()); const searchSetup = this.searchService.setup(core, { + bfetch, expressions, usageCollection, }); diff --git a/src/plugins/data/server/search/collectors/index.ts b/src/plugins/data/server/search/collectors/index.ts index 417dc1c2012d3..8ad6501d505eb 100644 --- a/src/plugins/data/server/search/collectors/index.ts +++ b/src/plugins/data/server/search/collectors/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export { usageProvider, SearchUsage } from './usage'; +export type { SearchUsage } from './usage'; +export { usageProvider, searchUsageObserver } from './usage'; diff --git a/src/plugins/data/server/search/collectors/usage.ts b/src/plugins/data/server/search/collectors/usage.ts index e1be92aa13c37..948175a41cb6b 100644 --- a/src/plugins/data/server/search/collectors/usage.ts +++ b/src/plugins/data/server/search/collectors/usage.ts @@ -17,8 +17,9 @@ * under the License. */ -import { CoreSetup } from 'kibana/server'; -import { Usage } from './register'; +import type { CoreSetup, Logger } from 'kibana/server'; +import type { IEsSearchResponse } from '../../../common'; +import type { Usage } from './register'; const SAVED_OBJECT_ID = 'search-telemetry'; @@ -74,3 +75,19 @@ export function usageProvider(core: CoreSetup): SearchUsage { trackSuccess: getTracker('successCount'), }; } + +/** + * Rxjs observer for easily doing `tap(searchUsageObserver(logger, usage))` in an rxjs chain. + */ +export function searchUsageObserver(logger: Logger, usage?: SearchUsage) { + return { + next(response: IEsSearchResponse) { + logger.debug(`trackSearchStatus:next ${response.rawResponse.took}`); + usage?.trackSuccess(response.rawResponse.took); + }, + error() { + logger.debug(`trackSearchStatus:error`); + usage?.trackError(); + }, + }; +} diff --git a/src/plugins/data/server/search/es_search/es_search_rxjs_utils.ts b/src/plugins/data/server/search/es_search/es_search_rxjs_utils.ts deleted file mode 100644 index 3ba2f9c4b2698..0000000000000 --- a/src/plugins/data/server/search/es_search/es_search_rxjs_utils.ts +++ /dev/null @@ -1,53 +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 { pipe } from 'rxjs'; -import { tap } from 'rxjs/operators'; - -import type { Logger, SearchResponse } from 'kibana/server'; -import type { SearchUsage } from '../collectors'; -import type { IEsSearchResponse, IKibanaSearchResponse } from '../../../common/search'; - -/** - * trackSearchStatus is a custom rxjs operator that can be used to track the progress of a search. - * @param Logger - * @param SearchUsage - */ -export const trackSearchStatus = < - KibanaResponse extends IKibanaSearchResponse = IEsSearchResponse> ->( - logger: Logger, - usage?: SearchUsage -) => { - return pipe( - tap( - (response: KibanaResponse) => { - const trackSuccessData = response.rawResponse.took; - - if (trackSuccessData !== undefined) { - logger.debug(`trackSearchStatus:next ${trackSuccessData}`); - usage?.trackSuccess(trackSuccessData); - } - }, - (err: any) => { - logger.debug(`trackSearchStatus:error ${err}`); - usage?.trackError(); - } - ) - ); -}; diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts index 3e2d415eac16f..620df9c8edcb0 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts @@ -16,20 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { Observable } from 'rxjs'; -import { first } from 'rxjs/operators'; - -import type { Logger } from 'kibana/server'; -import type { ApiResponse } from '@elastic/elasticsearch'; -import type { SharedGlobalConfig } from 'kibana/server'; - -import { doSearch, includeTotalLoaded, toKibanaSearchResponse, toSnakeCase } from '../../../common'; -import { trackSearchStatus } from './es_search_rxjs_utils'; -import { getDefaultSearchParams, getShardTimeout } from '../es_search'; - +import { from, Observable } from 'rxjs'; +import { first, tap } from 'rxjs/operators'; +import type { SearchResponse } from 'elasticsearch'; +import type { Logger, SharedGlobalConfig } from 'kibana/server'; import type { ISearchStrategy } from '../types'; -import type { SearchUsage } from '../collectors/usage'; -import type { IEsRawSearchResponse } from '../../../common'; +import type { SearchUsage } from '../collectors'; +import { getDefaultSearchParams, getShardTimeout, shimAbortSignal } from './request_utils'; +import { toKibanaSearchResponse } from './response_utils'; +import { searchUsageObserver } from '../collectors/usage'; export const esSearchStrategyProvider = ( config$: Observable, @@ -43,19 +38,18 @@ export const esSearchStrategyProvider = ( throw new Error(`Unsupported index pattern type ${request.indexType}`); } - return doSearch>(async () => { + const search = async () => { const config = await config$.pipe(first()).toPromise(); - const params = toSnakeCase({ + const params = { ...(await getDefaultSearchParams(uiSettingsClient)), ...getShardTimeout(config), ...request.params, - }); + }; + const promise = esClient.asCurrentUser.search>(params); + const { body } = await shimAbortSignal(promise, abortSignal); + return toKibanaSearchResponse(body); + }; - return esClient.asCurrentUser.search(params); - }, abortSignal).pipe( - toKibanaSearchResponse(), - trackSearchStatus(logger, usage), - includeTotalLoaded() - ); + return from(search()).pipe(tap(searchUsageObserver(logger, usage))); }, }); diff --git a/src/plugins/data/server/search/es_search/get_default_search_params.ts b/src/plugins/data/server/search/es_search/get_default_search_params.ts deleted file mode 100644 index a01b0885abf3b..0000000000000 --- a/src/plugins/data/server/search/es_search/get_default_search_params.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 { UI_SETTINGS } from '../../../common/constants'; -import type { SharedGlobalConfig, IUiSettingsClient } from '../../../../../core/server'; - -export function getShardTimeout(config: SharedGlobalConfig) { - const timeout = config.elasticsearch.shardTimeout.asMilliseconds(); - return timeout - ? { - timeout: `${timeout}ms`, - } - : {}; -} - -export async function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient) { - const maxConcurrentShardRequests = await uiSettingsClient.get( - UI_SETTINGS.COURIER_MAX_CONCURRENT_SHARD_REQUESTS - ); - return { - maxConcurrentShardRequests: - maxConcurrentShardRequests > 0 ? maxConcurrentShardRequests : undefined, - ignoreUnavailable: true, // Don't fail if the index/indices don't exist - trackTotalHits: true, - }; -} diff --git a/src/plugins/data/server/search/es_search/index.ts b/src/plugins/data/server/search/es_search/index.ts index 14e8a4e1b0245..f6487e3ef84f5 100644 --- a/src/plugins/data/server/search/es_search/index.ts +++ b/src/plugins/data/server/search/es_search/index.ts @@ -18,7 +18,6 @@ */ export { esSearchStrategyProvider } from './es_search_strategy'; -export * from './get_default_search_params'; -export * from './es_search_rxjs_utils'; - +export * from './request_utils'; +export * from './response_utils'; export { ES_SEARCH_STRATEGY, IEsSearchRequest, IEsSearchResponse } from '../../../common'; diff --git a/src/plugins/data/server/search/es_search/request_utils.test.ts b/src/plugins/data/server/search/es_search/request_utils.test.ts new file mode 100644 index 0000000000000..b63a6b3ae7e9b --- /dev/null +++ b/src/plugins/data/server/search/es_search/request_utils.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { getShardTimeout, getDefaultSearchParams, shimAbortSignal } from './request_utils'; +import { IUiSettingsClient, SharedGlobalConfig } from 'kibana/server'; + +const createSuccessTransportRequestPromise = ( + body: any, + { statusCode = 200 }: { statusCode?: number } = {} +) => { + const promise = Promise.resolve({ body, statusCode }) as any; + promise.abort = jest.fn(); + + return promise; +}; + +describe('request utils', () => { + describe('getShardTimeout', () => { + test('returns an empty object if the config does not contain a value', () => { + const result = getShardTimeout(({ + elasticsearch: { + shardTimeout: { + asMilliseconds: jest.fn(), + }, + }, + } as unknown) as SharedGlobalConfig); + expect(result).toEqual({}); + }); + + test('returns an empty object if the config contains 0', () => { + const result = getShardTimeout(({ + elasticsearch: { + shardTimeout: { + asMilliseconds: jest.fn().mockReturnValue(0), + }, + }, + } as unknown) as SharedGlobalConfig); + expect(result).toEqual({}); + }); + + test('returns a duration if the config >= 0', () => { + const result = getShardTimeout(({ + elasticsearch: { + shardTimeout: { + asMilliseconds: jest.fn().mockReturnValue(10), + }, + }, + } as unknown) as SharedGlobalConfig); + expect(result).toEqual({ timeout: '10ms' }); + }); + }); + + describe('getDefaultSearchParams', () => { + describe('max_concurrent_shard_requests', () => { + test('returns value if > 0', async () => { + const result = await getDefaultSearchParams(({ + get: jest.fn().mockResolvedValue(1), + } as unknown) as IUiSettingsClient); + expect(result).toHaveProperty('max_concurrent_shard_requests', 1); + }); + + test('returns undefined if === 0', async () => { + const result = await getDefaultSearchParams(({ + get: jest.fn().mockResolvedValue(0), + } as unknown) as IUiSettingsClient); + expect(result.max_concurrent_shard_requests).toBe(undefined); + }); + + test('returns undefined if undefined', async () => { + const result = await getDefaultSearchParams(({ + get: jest.fn(), + } as unknown) as IUiSettingsClient); + expect(result.max_concurrent_shard_requests).toBe(undefined); + }); + }); + + describe('other defaults', () => { + test('returns ignore_unavailable and track_total_hits', async () => { + const result = await getDefaultSearchParams(({ + get: jest.fn(), + } as unknown) as IUiSettingsClient); + expect(result).toHaveProperty('ignore_unavailable', true); + expect(result).toHaveProperty('track_total_hits', true); + }); + }); + }); + + describe('shimAbortSignal', () => { + test('aborts the promise if the signal is already aborted', async () => { + const promise = createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + controller.abort(); + shimAbortSignal(promise, controller.signal); + + expect(promise.abort).toHaveBeenCalled(); + }); + + test('aborts the promise if the signal is aborted', () => { + const promise = createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + shimAbortSignal(promise, controller.signal); + controller.abort(); + + expect(promise.abort).toHaveBeenCalled(); + }); + + test('returns the original promise', async () => { + const promise = createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + const response = await shimAbortSignal(promise, controller.signal); + + expect(response).toEqual(expect.objectContaining({ body: { success: true } })); + }); + + test('allows the promise to be aborted manually', () => { + const promise = createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + const enhancedPromise = shimAbortSignal(promise, controller.signal); + + enhancedPromise.abort(); + expect(promise.abort).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/plugins/data/server/search/es_search/request_utils.ts b/src/plugins/data/server/search/es_search/request_utils.ts new file mode 100644 index 0000000000000..03b7db7da8ffe --- /dev/null +++ b/src/plugins/data/server/search/es_search/request_utils.ts @@ -0,0 +1,66 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; +import type { Search } from '@elastic/elasticsearch/api/requestParams'; +import type { IUiSettingsClient, SharedGlobalConfig } from 'kibana/server'; +import { UI_SETTINGS } from '../../../common'; + +export function getShardTimeout(config: SharedGlobalConfig): Pick { + const timeout = config.elasticsearch.shardTimeout.asMilliseconds(); + return timeout ? { timeout: `${timeout}ms` } : {}; +} + +export async function getDefaultSearchParams( + uiSettingsClient: IUiSettingsClient +): Promise< + Pick +> { + const maxConcurrentShardRequests = await uiSettingsClient.get( + UI_SETTINGS.COURIER_MAX_CONCURRENT_SHARD_REQUESTS + ); + return { + max_concurrent_shard_requests: + maxConcurrentShardRequests > 0 ? maxConcurrentShardRequests : undefined, + ignore_unavailable: true, // Don't fail if the index/indices don't exist + track_total_hits: true, + }; +} + +/** + * Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 is resolved. + * Shims the `AbortSignal` behavior so that, if the given `signal` aborts, the `abort` method on the + * `TransportRequestPromise` is called, actually performing the cancellation. + * @internal + */ +export const shimAbortSignal = (promise: TransportRequestPromise, signal?: AbortSignal) => { + if (!signal) return promise; + const abortHandler = () => { + promise.abort(); + cleanup(); + }; + const cleanup = () => signal.removeEventListener('abort', abortHandler); + if (signal.aborted) { + promise.abort(); + } else { + signal.addEventListener('abort', abortHandler); + promise.then(cleanup, cleanup); + } + return promise; +}; diff --git a/src/plugins/data/server/search/es_search/response_utils.test.ts b/src/plugins/data/server/search/es_search/response_utils.test.ts new file mode 100644 index 0000000000000..f93625980a69c --- /dev/null +++ b/src/plugins/data/server/search/es_search/response_utils.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { getTotalLoaded, toKibanaSearchResponse } from './response_utils'; +import { SearchResponse } from 'elasticsearch'; + +describe('response utils', () => { + describe('getTotalLoaded', () => { + it('returns the total/loaded, not including skipped', () => { + const result = getTotalLoaded(({ + _shards: { + successful: 10, + failed: 5, + skipped: 5, + total: 100, + }, + } as unknown) as SearchResponse); + + expect(result).toEqual({ + total: 100, + loaded: 15, + }); + }); + }); + + describe('toKibanaSearchResponse', () => { + it('returns rawResponse, isPartial, isRunning, total, and loaded', () => { + const result = toKibanaSearchResponse(({ + _shards: { + successful: 10, + failed: 5, + skipped: 5, + total: 100, + }, + } as unknown) as SearchResponse); + + expect(result).toEqual({ + rawResponse: { + _shards: { + successful: 10, + failed: 5, + skipped: 5, + total: 100, + }, + }, + isRunning: false, + isPartial: false, + total: 100, + loaded: 15, + }); + }); + }); +}); diff --git a/src/plugins/data/common/search/es_search/get_total_loaded.ts b/src/plugins/data/server/search/es_search/response_utils.ts similarity index 69% rename from src/plugins/data/common/search/es_search/get_total_loaded.ts rename to src/plugins/data/server/search/es_search/response_utils.ts index 233bcf8186666..2f502f55057b8 100644 --- a/src/plugins/data/common/search/es_search/get_total_loaded.ts +++ b/src/plugins/data/server/search/es_search/response_utils.ts @@ -17,14 +17,28 @@ * under the License. */ -import type { ShardsResponse } from 'elasticsearch'; +import { SearchResponse } from 'elasticsearch'; /** * Get the `total`/`loaded` for this response (see `IKibanaSearchResponse`). Note that `skipped` is * not included as it is already included in `successful`. * @internal */ -export function getTotalLoaded({ total, failed, successful }: ShardsResponse) { +export function getTotalLoaded(response: SearchResponse) { + const { total, failed, successful } = response._shards; const loaded = failed + successful; return { total, loaded }; } + +/** + * Get the Kibana representation of this response (see `IKibanaSearchResponse`). + * @internal + */ +export function toKibanaSearchResponse(rawResponse: SearchResponse) { + return { + rawResponse, + isPartial: false, + isRunning: false, + ...getTotalLoaded(rawResponse), + }; +} diff --git a/src/plugins/data/server/search/index.ts b/src/plugins/data/server/search/index.ts index 1be641401b29c..3001bbe3c2f38 100644 --- a/src/plugins/data/server/search/index.ts +++ b/src/plugins/data/server/search/index.ts @@ -19,6 +19,6 @@ export * from './types'; export * from './es_search'; -export { usageProvider, SearchUsage } from './collectors'; +export { usageProvider, SearchUsage, searchUsageObserver } from './collectors'; export * from './aggs'; export { shimHitsTotal } from './routes'; diff --git a/src/plugins/data/server/search/routes/call_msearch.ts b/src/plugins/data/server/search/routes/call_msearch.ts index 603b3ed867b23..923369297889b 100644 --- a/src/plugins/data/server/search/routes/call_msearch.ts +++ b/src/plugins/data/server/search/routes/call_msearch.ts @@ -24,9 +24,8 @@ import { SearchResponse } from 'elasticsearch'; import { IUiSettingsClient, IScopedClusterClient, SharedGlobalConfig } from 'src/core/server'; import type { MsearchRequestBody, MsearchResponse } from '../../../common/search/search_source'; -import { toSnakeCase, shimAbortSignal } from '../../../common/search/es_search'; import { shimHitsTotal } from './shim_hits_total'; -import { getShardTimeout, getDefaultSearchParams } from '..'; +import { getShardTimeout, getDefaultSearchParams, shimAbortSignal } from '..'; /** @internal */ export function convertRequestBody( @@ -71,7 +70,7 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { const timeout = getShardTimeout(config); // trackTotalHits is not supported by msearch - const { trackTotalHits, ...defaultParams } = await getDefaultSearchParams(uiSettings); + const { track_total_hits: _, ...defaultParams } = await getDefaultSearchParams(uiSettings); const body = convertRequestBody(params.body, timeout); @@ -81,7 +80,7 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { body, }, { - querystring: toSnakeCase(defaultParams), + querystring: defaultParams, } ), params.signal diff --git a/src/plugins/data/server/search/search_service.test.ts b/src/plugins/data/server/search/search_service.test.ts index 0700afd8d6c83..8a52d1d415f9b 100644 --- a/src/plugins/data/server/search/search_service.test.ts +++ b/src/plugins/data/server/search/search_service.test.ts @@ -25,6 +25,8 @@ import { createFieldFormatsStartMock } from '../field_formats/mocks'; import { createIndexPatternsStartMock } from '../index_patterns/mocks'; import { SearchService, SearchServiceSetupDependencies } from './search_service'; +import { bfetchPluginMock } from '../../../bfetch/server/mocks'; +import { of } from 'rxjs'; describe('Search service', () => { let plugin: SearchService; @@ -35,15 +37,29 @@ describe('Search service', () => { const mockLogger: any = { debug: () => {}, }; - plugin = new SearchService(coreMock.createPluginInitializerContext({}), mockLogger); + const context = coreMock.createPluginInitializerContext({}); + context.config.create = jest.fn().mockImplementation(() => { + return of({ + search: { + aggs: { + shardDelay: { + enabled: true, + }, + }, + }, + }); + }); + plugin = new SearchService(context, mockLogger); mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); }); describe('setup()', () => { it('exposes proper contract', async () => { + const bfetch = bfetchPluginMock.createSetupContract(); const setup = plugin.setup(mockCoreSetup, ({ packageInfo: { version: '8' }, + bfetch, expressions: { registerFunction: jest.fn(), registerType: jest.fn(), diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index b44980164d097..a9539a8fd3c15 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -29,7 +29,8 @@ import { SharedGlobalConfig, StartServicesAccessor, } from 'src/core/server'; -import { first, switchMap } from 'rxjs/operators'; +import { catchError, first, map, switchMap } from 'rxjs/operators'; +import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { ISearchSetup, @@ -43,7 +44,7 @@ import { AggsService } from './aggs'; import { FieldFormatsStart } from '../field_formats'; import { IndexPatternsServiceStart } from '../index_patterns'; -import { getCallMsearch, registerMsearchRoute, registerSearchRoute } from './routes'; +import { getCallMsearch, registerMsearchRoute, registerSearchRoute, shimHitsTotal } from './routes'; import { ES_SEARCH_STRATEGY, esSearchStrategyProvider } from './es_search'; import { DataPluginStart } from '../plugin'; import { UsageCollectionSetup } from '../../../usage_collection/server'; @@ -85,6 +86,7 @@ type StrategyMap = Record>; /** @internal */ export interface SearchServiceSetupDependencies { + bfetch: BfetchServerSetup; expressions: ExpressionsServerSetup; usageCollection?: UsageCollectionSetup; } @@ -106,6 +108,7 @@ export class SearchService implements Plugin { private readonly searchSourceService = new SearchSourceService(); private defaultSearchStrategyName: string = ES_SEARCH_STRATEGY; private searchStrategies: StrategyMap = {}; + private coreStart?: CoreStart; private sessionService: BackgroundSessionService = new BackgroundSessionService(); constructor( @@ -115,7 +118,7 @@ export class SearchService implements Plugin { public setup( core: CoreSetup<{}, DataPluginStart>, - { expressions, usageCollection }: SearchServiceSetupDependencies + { bfetch, expressions, usageCollection }: SearchServiceSetupDependencies ): ISearchSetup { const usage = usageCollection ? usageProvider(core) : undefined; @@ -128,10 +131,13 @@ export class SearchService implements Plugin { registerMsearchRoute(router, routeDependencies); registerSessionRoutes(router); + core.getStartServices().then(([coreStart]) => { + this.coreStart = coreStart; + }); + core.http.registerRouteHandlerContext('search', async (context, request) => { - const [coreStart] = await core.getStartServices(); - const search = this.asScopedProvider(coreStart)(request); - const session = this.sessionService.asScopedProvider(coreStart)(request); + const search = this.asScopedProvider(this.coreStart!)(request); + const session = this.sessionService.asScopedProvider(this.coreStart!)(request); return { ...search, session }; }); @@ -146,6 +152,44 @@ export class SearchService implements Plugin { ) ); + bfetch.addBatchProcessingRoute< + { request: IKibanaSearchResponse; options?: ISearchOptions }, + any + >('/internal/bsearch', (request) => { + const search = this.asScopedProvider(this.coreStart!)(request); + + return { + onBatchItem: async ({ request: requestData, options }) => { + return search + .search(requestData, options) + .pipe( + first(), + map((response) => { + return { + ...response, + ...{ + rawResponse: shimHitsTotal(response.rawResponse), + }, + }; + }), + catchError((err) => { + // eslint-disable-next-line no-throw-literal + throw { + statusCode: err.statusCode || 500, + body: { + message: err.message, + attributes: { + error: err.body?.error || err.message, + }, + }, + }; + }) + ) + .toPromise(); + }, + }; + }); + core.savedObjects.registerType(searchTelemetry); if (usageCollection) { registerUsageCollector(usageCollection, this.initializerContext); diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 94114288eb1f3..86ec784834ace 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -9,12 +9,14 @@ import { Adapters } from 'src/plugins/inspector/common'; import { ApiResponse } from '@elastic/elasticsearch'; import { Assign } from '@kbn/utility-types'; import { BehaviorSubject } from 'rxjs'; +import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { ConfigDeprecationProvider } from '@kbn/config'; 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,11 +29,13 @@ 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'; import { ISearchSource } from 'src/plugins/data/public'; import { IUiSettingsClient } from 'src/core/server'; +import { IUiSettingsClient as IUiSettingsClient_3 } from 'kibana/server'; import { KibanaRequest } from 'src/core/server'; import { LegacyAPICaller } from 'src/core/server'; import { Logger } from 'src/core/server'; @@ -56,8 +60,9 @@ import { SavedObjectsClientContract as SavedObjectsClientContract_2 } from 'kiba import { Search } from '@elastic/elasticsearch/api/requestParams'; import { SearchResponse } from 'elasticsearch'; import { SerializedFieldFormat as SerializedFieldFormat_2 } from 'src/plugins/expressions/common'; -import { ShardsResponse } from 'elasticsearch'; +import { SharedGlobalConfig as SharedGlobalConfig_2 } from 'kibana/server'; import { ToastInputFields } from 'src/core/public/notifications'; +import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; import { UiStatsMetricType } from '@kbn/analytics'; @@ -299,6 +304,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) @@ -400,25 +413,15 @@ export function getCapabilitiesForRollupIndices(indices: { [key: string]: any; }; -// Warning: (ae-forgotten-export) The symbol "IUiSettingsClient" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "getDefaultSearchParams" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient_2): Promise<{ - maxConcurrentShardRequests: number | undefined; - ignoreUnavailable: boolean; - trackTotalHits: boolean; -}>; +export function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient_3): Promise>; -// Warning: (ae-forgotten-export) The symbol "SharedGlobalConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "getShardTimeout" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function getShardTimeout(config: SharedGlobalConfig): { - timeout: string; -} | { - timeout?: undefined; -}; +export function getShardTimeout(config: SharedGlobalConfig_2): Pick; // Warning: (ae-forgotten-export) The symbol "IIndexPattern" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "getTime" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -429,6 +432,12 @@ export function getTime(indexPattern: IIndexPattern | undefined, timeRange: Time fieldName?: string; }): import("../..").RangeFilter | undefined; +// @internal +export function getTotalLoaded(response: SearchResponse): { + total: number; + loaded: number; +}; + // Warning: (ae-missing-release-tag) "IAggConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -445,18 +454,6 @@ export type IAggConfigs = AggConfigs; // @public (undocumented) export type IAggType = AggType; -// Warning: (ae-missing-release-tag) "IEsRawSearchResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export interface IEsRawSearchResponse extends SearchResponse { - // (undocumented) - id?: string; - // (undocumented) - is_partial?: boolean; - // (undocumented) - is_running?: boolean; -} - // Warning: (ae-forgotten-export) The symbol "IKibanaSearchRequest" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ISearchRequestParams" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "IEsSearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -952,7 +949,7 @@ export function parseInterval(interval: string): moment.Duration | null; export class Plugin implements Plugin_2 { constructor(initializerContext: PluginInitializerContext_2); // (undocumented) - setup(core: CoreSetup, { expressions, usageCollection }: DataPluginSetupDependencies): { + setup(core: CoreSetup, { bfetch, expressions, usageCollection }: DataPluginSetupDependencies): { __enhance: (enhancements: DataEnhancements) => void; search: ISearchSetup; fieldFormats: { @@ -1030,24 +1027,6 @@ export interface RefreshInterval { // // @public (undocumented) export const search: { - esSearch: { - utils: { - doSearch: (searchMethod: () => Promise, abortSignal?: AbortSignal | undefined) => import("rxjs").Observable; - shimAbortSignal: >(promise: T, signal: AbortSignal | undefined) => T; - trackSearchStatus: = import("./search").IEsSearchResponse>>(logger: import("src/core/server").Logger, usage?: import("./search").SearchUsage | undefined) => import("rxjs").UnaryFunction, import("rxjs").Observable>; - includeTotalLoaded: () => import("rxjs").OperatorFunction>, { - total: number; - loaded: number; - id?: string | undefined; - isRunning?: boolean | undefined; - isPartial?: boolean | undefined; - rawResponse: import("elasticsearch").SearchResponse; - }>; - toKibanaSearchResponse: = import("../common").IEsRawSearchResponse, KibanaResponse_1 extends import("../common").IKibanaSearchResponse = import("../common").IKibanaSearchResponse>() => import("rxjs").OperatorFunction, KibanaResponse_1>; - getTotalLoaded: typeof getTotalLoaded; - toSnakeCase: typeof toSnakeCase; - }; - }; aggs: { CidrMask: typeof CidrMask; dateHistogramInterval: typeof dateHistogramInterval; @@ -1074,6 +1053,7 @@ export const search: { siblingPipelineType: string; termsAggFilter: string[]; toAbsoluteDates: typeof toAbsoluteDates; + calcAutoIntervalLessThan: typeof calcAutoIntervalLessThan; }; getRequestInspectorStats: typeof getRequestInspectorStats; getResponseInspectorStats: typeof getResponseInspectorStats; @@ -1103,6 +1083,17 @@ export interface SearchUsage { trackSuccess(duration: number): Promise; } +// Warning: (ae-missing-release-tag) "searchUsageObserver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function searchUsageObserver(logger: Logger_2, usage?: SearchUsage): { + next(response: IEsSearchResponse): void; + error(): void; +}; + +// @internal +export const shimAbortSignal: (promise: TransportRequestPromise, signal?: AbortSignal | undefined) => TransportRequestPromise; + // @internal export function shimHitsTotal(response: SearchResponse): { hits: { @@ -1165,6 +1156,15 @@ export type TimeRange = { mode?: 'absolute' | 'relative'; }; +// @internal +export function toKibanaSearchResponse(rawResponse: SearchResponse): { + total: number; + loaded: number; + rawResponse: SearchResponse; + isPartial: boolean; + isRunning: boolean; +}; + // Warning: (ae-missing-release-tag) "UI_SETTINGS" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1216,42 +1216,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:248:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:248:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:248:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:248:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:250:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:251:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:260:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:261:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:262:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:266:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:267:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:271:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:274:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:275: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/plugin.ts:90:74 - (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 // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/server/ui_settings.ts b/src/plugins/data/server/ui_settings.ts index 9393700a0e771..f5360f626ac66 100644 --- a/src/plugins/data/server/ui_settings.ts +++ b/src/plugins/data/server/ui_settings.ts @@ -267,14 +267,13 @@ export function getUiSettings(): Record> { }, [UI_SETTINGS.COURIER_BATCH_SEARCHES]: { name: i18n.translate('data.advancedSettings.courier.batchSearchesTitle', { - defaultMessage: 'Batch concurrent searches', + defaultMessage: 'Use legacy search', }), value: false, type: 'boolean', description: i18n.translate('data.advancedSettings.courier.batchSearchesText', { - defaultMessage: `When disabled, dashboard panels will load individually, and search requests will terminate when users navigate - away or update the query. When enabled, dashboard panels will load together when all of the data is loaded, and - searches will not terminate.`, + defaultMessage: `Kibana uses a new search and batching infrastructure. + Enable this option if you prefer to fallback to the legacy synchronous behavior`, }), deprecation: { message: i18n.translate('data.advancedSettings.courier.batchSearchesTextDeprecation', { diff --git a/src/plugins/discover/public/__mocks__/config.ts b/src/plugins/discover/public/__mocks__/config.ts new file mode 100644 index 0000000000000..a6cdfedd795b5 --- /dev/null +++ b/src/plugins/discover/public/__mocks__/config.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IUiSettingsClient } from '../../../../core/public'; + +export const configMock = ({ + get: (key: string) => { + if (key === 'defaultIndex') { + return 'the-index-pattern-id'; + } + + return ''; + }, +} as unknown) as IUiSettingsClient; diff --git a/src/plugins/discover/public/__mocks__/index_pattern.ts b/src/plugins/discover/public/__mocks__/index_pattern.ts new file mode 100644 index 0000000000000..696079ec72a73 --- /dev/null +++ b/src/plugins/discover/public/__mocks__/index_pattern.ts @@ -0,0 +1,74 @@ +/* + * 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 { IndexPattern, indexPatterns } from '../kibana_services'; +import { IIndexPatternFieldList } from '../../../data/common/index_patterns/fields'; + +const fields = [ + { + name: '_index', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'message', + type: 'string', + scripted: false, + filterable: false, + }, + { + name: 'extension', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'bytes', + type: 'number', + scripted: false, + filterable: true, + }, + { + name: 'scripted', + type: 'number', + scripted: true, + filterable: false, + }, +] as IIndexPatternFieldList; + +fields.getByName = (name: string) => { + return fields.find((field) => field.name === name); +}; + +const indexPattern = ({ + id: 'the-index-pattern-id', + title: 'the-index-pattern-title', + metaFields: ['_index', '_score'], + flattenHit: undefined, + formatHit: jest.fn((hit) => hit._source), + fields, + getComputedFields: () => ({}), + getSourceFiltering: () => ({}), + getFieldByName: () => ({}), +} as unknown) as IndexPattern; + +indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields); + +export const indexPatternMock = indexPattern; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.js b/src/plugins/discover/public/__mocks__/index_patterns.ts similarity index 69% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.js rename to src/plugins/discover/public/__mocks__/index_patterns.ts index 682befe9ab050..f413a111a1d79 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.js +++ b/src/plugins/discover/public/__mocks__/index_patterns.ts @@ -17,19 +17,16 @@ * under the License. */ -import moment from 'moment'; +import { IndexPatternsService } from '../../../data/common'; +import { indexPatternMock } from './index_pattern'; -export const getTimerange = (req) => { - const { min, max } = req.payload.timerange; - - return { - from: moment.utc(min), - to: moment.utc(max), - }; -}; - -export const getTimerangeDuration = (req) => { - const { from, to } = getTimerange(req); - - return moment.duration(to.valueOf() - from.valueOf(), 'ms'); -}; +export const indexPatternsMock = ({ + getCache: () => { + return [indexPatternMock]; + }, + get: (id: string) => { + if (id === 'the-index-pattern-id') { + return indexPatternMock; + } + }, +} as unknown) as IndexPatternsService; diff --git a/packages/kbn-es-archiver/src/lib/streams/map_stream.ts b/src/plugins/discover/public/__mocks__/saved_search.ts similarity index 54% rename from packages/kbn-es-archiver/src/lib/streams/map_stream.ts rename to src/plugins/discover/public/__mocks__/saved_search.ts index e88c512a38653..11f36fdfde67c 100644 --- a/packages/kbn-es-archiver/src/lib/streams/map_stream.ts +++ b/src/plugins/discover/public/__mocks__/saved_search.ts @@ -17,20 +17,25 @@ * under the License. */ -import { Transform } from 'stream'; +import { SavedSearch } from '../saved_searches'; -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); - } +export const savedSearchMock = ({ + id: 'the-saved-search-id', + type: 'search', + attributes: { + title: 'the-saved-search-title', + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"highlightAll":true,"version":true,"query":{"query":"foo : \\"bar\\" ","language":"kuery"},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', + }, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: 'the-index-pattern-id', }, - }); -} + ], + migrationVersion: { search: '7.5.0' }, + error: undefined, +} as unknown) as SavedSearch; diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 9319c58db3e33..272c2f2ca6187 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -18,8 +18,7 @@ */ import _ from 'lodash'; -import React from 'react'; -import { Subscription, Subject, merge } from 'rxjs'; +import { merge, Subject, Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import moment from 'moment'; import dateMath from '@elastic/datemath'; @@ -28,31 +27,52 @@ import { getState, splitState } from './discover_state'; import { RequestAdapter } from '../../../../inspector/public'; import { + connectToQueryState, esFilters, indexPatterns as indexPatternsUtils, - connectToQueryState, syncQueryStateWithUrl, } from '../../../../data/public'; -import { SavedObjectSaveModal, showSaveModal } from '../../../../saved_objects/public'; -import { getSortArray, getSortForSearchSource } from './doc_table'; +import { getSortArray } from './doc_table'; import { createFixedScroll } from './directives/fixed_scroll'; import * as columnActions from './doc_table/actions/columns'; import indexTemplateLegacy from './discover_legacy.html'; -import { showOpenSearchPanel } from '../components/top_nav/show_open_search_panel'; import { addHelpMenuToAppChrome } from '../components/help_menu/help_menu_util'; import { discoverResponseHandler } from './response_handler'; import { + getAngularModule, + getHeaderActionMenuMounter, getRequestInspectorStats, getResponseInspectorStats, getServices, - getHeaderActionMenuMounter, getUrlTracker, - unhashUrl, + redirectWhenMissing, subscribeWithScope, tabifyAggResponse, - getAngularModule, - redirectWhenMissing, } from '../../kibana_services'; +import { + getRootBreadcrumbs, + getSavedSearchBreadcrumbs, + setBreadcrumbsTitle, +} from '../helpers/breadcrumbs'; +import { validateTimeRange } from '../helpers/validate_time_range'; +import { popularizeField } from '../helpers/popularize_field'; +import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_pattern_app_state'; +import { addFatalError } from '../../../../kibana_legacy/public'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator'; +import { removeQueryParam, getQueryParams } from '../../../../kibana_utils/public'; +import { + DEFAULT_COLUMNS_SETTING, + MODIFY_COLUMNS_ON_SWITCH, + SAMPLE_SIZE_SETTING, + SEARCH_ON_PAGE_LOAD_SETTING, +} from '../../../common'; +import { resolveIndexPattern, loadIndexPattern } from '../helpers/resolve_index_pattern'; +import { getTopNavLinks } from '../components/top_nav/get_top_nav_links'; +import { updateSearchSource } from '../helpers/update_search_source'; +import { calcFieldCounts } from '../helpers/calc_field_counts'; + +const services = getServices(); const { core, @@ -61,30 +81,11 @@ const { history: getHistory, indexPatterns, filterManager, - share, timefilter, toastNotifications, uiSettings: config, trackUiMetric, -} = getServices(); - -import { getRootBreadcrumbs, getSavedSearchBreadcrumbs } from '../helpers/breadcrumbs'; -import { validateTimeRange } from '../helpers/validate_time_range'; -import { popularizeField } from '../helpers/popularize_field'; -import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_pattern_app_state'; -import { getIndexPatternId } from '../helpers/get_index_pattern_id'; -import { addFatalError } from '../../../../kibana_legacy/public'; -import { - DEFAULT_COLUMNS_SETTING, - SAMPLE_SIZE_SETTING, - SORT_DEFAULT_ORDER_SETTING, - SEARCH_ON_PAGE_LOAD_SETTING, - DOC_HIDE_TIME_COLUMN_SETTING, - MODIFY_COLUMNS_ON_SWITCH, -} from '../../../common'; -import { METRIC_TYPE } from '@kbn/analytics'; -import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator'; -import { removeQueryParam, getQueryParams } from '../../../../kibana_utils/public'; +} = services; const fetchStatuses = { UNINITIALIZED: 'uninitialized', @@ -132,24 +133,7 @@ app.config(($routeProvider) => { const { appStateContainer } = getState({ history }); const { index } = appStateContainer.getState(); return Promise.props({ - ip: indexPatterns.getCache().then((indexPatternList) => { - /** - * In making the indexPattern modifiable it was placed in appState. Unfortunately, - * the load order of AppState conflicts with the load order of many other things - * so in order to get the name of the index we should use, and to switch to the - * default if necessary, we parse the appState with a temporary State object and - * then destroy it immediatly after we're done - * - * @type {State} - */ - const id = getIndexPatternId(index, indexPatternList, config.get('defaultIndex')); - return Promise.props({ - list: indexPatternList, - loaded: indexPatterns.get(id), - stateVal: index, - stateValFound: !!index && id === index, - }); - }), + ip: loadIndexPattern(index, data.indexPatterns, config), savedSearch: getServices() .getSavedSearchById(savedSearchId) .then((savedSearch) => { @@ -204,7 +188,11 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise let inspectorRequest; const savedSearch = $route.current.locals.savedObjects.savedSearch; $scope.searchSource = savedSearch.searchSource; - $scope.indexPattern = resolveIndexPatternLoading(); + $scope.indexPattern = resolveIndexPattern( + $route.current.locals.savedObjects.ip, + $scope.searchSource, + toastNotifications + ); //used for functional testing $scope.fetchCounter = 0; @@ -216,22 +204,22 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise // used for restoring background session let isInitialSearch = true; + const state = getState({ + getStateDefaults, + storeInSessionStorage: config.get('state:storeInSessionStorage'), + history, + toasts: core.notifications.toasts, + }); const { appStateContainer, startSync: startStateSync, stopSync: stopStateSync, setAppState, replaceUrlAppState, - isAppStateDirty, kbnUrlStateStorage, getPreviousAppState, - resetInitialAppState, - } = getState({ - defaultAppState: getStateDefaults(), - storeInSessionStorage: config.get('state:storeInSessionStorage'), - history, - toasts: core.notifications.toasts, - }); + } = state; + if (appStateContainer.getState().index !== $scope.indexPattern.id) { //used index pattern is different than the given by url/state which is invalid setAppState({ index: $scope.indexPattern.id }); @@ -349,145 +337,36 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise unlistenHistoryBasePath(); }); - const getTopNavLinks = () => { - const newSearch = { - id: 'new', - label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { - defaultMessage: 'New', - }), - description: i18n.translate('discover.localMenu.newSearchDescription', { - defaultMessage: 'New Search', - }), - run: function () { - $scope.$evalAsync(() => { - history.push('/'); - }); - }, - testId: 'discoverNewButton', - }; - - const saveSearch = { - id: 'save', - label: i18n.translate('discover.localMenu.saveTitle', { - defaultMessage: 'Save', - }), - description: i18n.translate('discover.localMenu.saveSearchDescription', { - defaultMessage: 'Save Search', - }), - testId: 'discoverSaveButton', - run: async () => { - const onSave = ({ - newTitle, - newCopyOnSave, - isTitleDuplicateConfirmed, - onTitleDuplicate, - }) => { - const currentTitle = savedSearch.title; - savedSearch.title = newTitle; - savedSearch.copyOnSave = newCopyOnSave; - const saveOptions = { - confirmOverwrite: false, - isTitleDuplicateConfirmed, - onTitleDuplicate, - }; - return saveDataSource(saveOptions).then((response) => { - // If the save wasn't successful, put the original values back. - if (!response.id || response.error) { - savedSearch.title = currentTitle; - } else { - resetInitialAppState(); - } - return response; - }); - }; - - const saveModal = ( - {}} - title={savedSearch.title} - showCopyOnSave={!!savedSearch.id} - objectType="search" - description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { - defaultMessage: - 'Save your Discover search so you can use it in visualizations and dashboards', - })} - showDescription={false} - /> - ); - showSaveModal(saveModal, core.i18n.Context); - }, - }; - - const openSearch = { - id: 'open', - label: i18n.translate('discover.localMenu.openTitle', { - defaultMessage: 'Open', - }), - description: i18n.translate('discover.localMenu.openSavedSearchDescription', { - defaultMessage: 'Open Saved Search', - }), - testId: 'discoverOpenButton', - run: () => { - showOpenSearchPanel({ - makeUrl: (searchId) => `#/view/${encodeURIComponent(searchId)}`, - I18nContext: core.i18n.Context, - }); - }, - }; - - const shareSearch = { - id: 'share', - label: i18n.translate('discover.localMenu.shareTitle', { - defaultMessage: 'Share', - }), - description: i18n.translate('discover.localMenu.shareSearchDescription', { - defaultMessage: 'Share Search', - }), - testId: 'shareTopNavButton', - run: async (anchorElement) => { - const sharingData = await this.getSharingData(); - share.toggleShareContextMenu({ - anchorElement, - allowEmbed: false, - allowShortUrl: uiCapabilities.discover.createShortUrl, - shareableUrl: unhashUrl(window.location.href), - objectId: savedSearch.id, - objectType: 'search', - sharingData: { - ...sharingData, - title: savedSearch.title, - }, - isDirty: !savedSearch.id || isAppStateDirty(), - }); - }, - }; - - const inspectSearch = { - id: 'inspect', - label: i18n.translate('discover.localMenu.inspectTitle', { - defaultMessage: 'Inspect', - }), - description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', { - defaultMessage: 'Open Inspector for search', - }), - testId: 'openInspectorButton', - run() { - getServices().inspector.open(inspectorAdapters, { - title: savedSearch.title, - }); - }, - }; + const getFieldCounts = async () => { + // the field counts aren't set until we have the data back, + // so we wait for the fetch to be done before proceeding + if ($scope.fetchStatus === fetchStatuses.COMPLETE) { + return $scope.fieldCounts; + } - return [ - newSearch, - ...(uiCapabilities.discover.save ? [saveSearch] : []), - openSearch, - shareSearch, - inspectSearch, - ]; + return await new Promise((resolve) => { + const unwatch = $scope.$watch('fetchStatus', (newValue) => { + if (newValue === fetchStatuses.COMPLETE) { + unwatch(); + resolve($scope.fieldCounts); + } + }); + }); }; - $scope.topNavMenu = getTopNavLinks(); + + $scope.topNavMenu = getTopNavLinks({ + getFieldCounts, + indexPattern: $scope.indexPattern, + inspectorAdapters, + navigateTo: (path) => { + $scope.$evalAsync(() => { + history.push(path); + }); + }, + savedSearch, + services, + state, + }); $scope.searchSource .setField('index', $scope.indexPattern) @@ -511,96 +390,8 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : ''; chrome.docTitle.change(`Discover${pageTitleSuffix}`); - const discoverBreadcrumbsTitle = i18n.translate('discover.discoverBreadcrumbTitle', { - defaultMessage: 'Discover', - }); - - if (savedSearch.id && savedSearch.title) { - chrome.setBreadcrumbs([ - { - text: discoverBreadcrumbsTitle, - href: '#/', - }, - { text: savedSearch.title }, - ]); - } else { - chrome.setBreadcrumbs([ - { - text: discoverBreadcrumbsTitle, - }, - ]); - } - const getFieldCounts = async () => { - // the field counts aren't set until we have the data back, - // so we wait for the fetch to be done before proceeding - if ($scope.fetchStatus === fetchStatuses.COMPLETE) { - return $scope.fieldCounts; - } - - return await new Promise((resolve) => { - const unwatch = $scope.$watch('fetchStatus', (newValue) => { - if (newValue === fetchStatuses.COMPLETE) { - unwatch(); - resolve($scope.fieldCounts); - } - }); - }); - }; - - const getSharingDataFields = async (selectedFields, timeFieldName, hideTimeColumn) => { - if (selectedFields.length === 1 && selectedFields[0] === '_source') { - const fieldCounts = await getFieldCounts(); - return { - searchFields: null, - selectFields: _.keys(fieldCounts).sort(), - }; - } - - const fields = - timeFieldName && !hideTimeColumn ? [timeFieldName, ...selectedFields] : selectedFields; - return { - searchFields: fields, - selectFields: fields, - }; - }; - - this.getSharingData = async () => { - const searchSource = $scope.searchSource.createCopy(); - - const { searchFields, selectFields } = await getSharingDataFields( - $scope.state.columns, - $scope.indexPattern.timeFieldName, - config.get(DOC_HIDE_TIME_COLUMN_SETTING) - ); - searchSource.setField('fields', searchFields); - searchSource.setField( - 'sort', - getSortForSearchSource( - $scope.state.sort, - $scope.indexPattern, - config.get(SORT_DEFAULT_ORDER_SETTING) - ) - ); - searchSource.setField('highlight', null); - searchSource.setField('highlightAll', null); - searchSource.setField('aggs', null); - searchSource.setField('size', null); - - const body = await searchSource.getSearchRequestBody(); - return { - searchRequest: { - index: searchSource.getField('index').title, - body, - }, - fields: selectFields, - metaFields: $scope.indexPattern.metaFields, - conflictedTypesFields: $scope.indexPattern.fields - .filter((f) => f.type === 'conflict') - .map((f) => f.name), - indexPatternId: searchSource.getField('index').id, - }; - }; + setBreadcrumbsTitle(savedSearch, chrome); function getStateDefaults() { const query = $scope.searchSource.getField('query') || data.query.queryString.getDefaultQuery(); @@ -739,57 +530,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise }); }); - async function saveDataSource(saveOptions) { - await $scope.updateDataSource(); - - savedSearch.columns = $scope.state.columns; - savedSearch.sort = $scope.state.sort; - - try { - const id = await savedSearch.save(saveOptions); - $scope.$evalAsync(() => { - if (id) { - toastNotifications.addSuccess({ - title: i18n.translate('discover.notifications.savedSearchTitle', { - defaultMessage: `Search '{savedSearchTitle}' was saved`, - values: { - savedSearchTitle: savedSearch.title, - }, - }), - 'data-test-subj': 'saveSearchSuccess', - }); - - if (savedSearch.id !== $route.current.params.id) { - history.push(`/view/${encodeURIComponent(savedSearch.id)}`); - } else { - // Update defaults so that "reload saved query" functions correctly - setAppState(getStateDefaults()); - chrome.docTitle.change(savedSearch.lastSavedTitle); - chrome.setBreadcrumbs([ - { - text: discoverBreadcrumbsTitle, - href: '#/', - }, - { text: savedSearch.title }, - ]); - } - } - }); - return { id }; - } catch (saveError) { - toastNotifications.addDanger({ - title: i18n.translate('discover.notifications.notSavedSearchTitle', { - defaultMessage: `Search '{savedSearchTitle}' was not saved.`, - values: { - savedSearchTitle: savedSearch.title, - }, - }), - text: saveError.message, - }); - return { error: saveError }; - } - } - $scope.opts.fetch = $scope.fetch = function () { // ignore requests to fetch before the app inits if (!init.complete) return; @@ -907,16 +647,11 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise $scope.hits = resp.hits.total; $scope.rows = resp.hits.hits; - // if we haven't counted yet, reset the counts - const counts = ($scope.fieldCounts = $scope.fieldCounts || {}); - - $scope.rows.forEach((hit) => { - const fields = Object.keys($scope.indexPattern.flattenHit(hit)); - fields.forEach((fieldName) => { - counts[fieldName] = (counts[fieldName] || 0) + 1; - }); - }); - + $scope.fieldCounts = calcFieldCounts( + $scope.fieldCounts || {}, + resp.hits.hits, + $scope.indexPattern + ); $scope.fetchStatus = fetchStatuses.COMPLETE; } @@ -944,13 +679,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise }; }; - $scope.toMoment = function (datetime) { - if (!datetime) { - return; - } - return moment(datetime).format(config.get('dateFormat')); - }; - $scope.resetQuery = function () { history.push( $route.current.params.id ? `/view/${encodeURIComponent($route.current.params.id)}` : '/' @@ -979,20 +707,11 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise }; $scope.updateDataSource = () => { - const { indexPattern, searchSource } = $scope; - searchSource - .setField('index', $scope.indexPattern) - .setField('size', $scope.opts.sampleSize) - .setField( - 'sort', - getSortForSearchSource( - $scope.state.sort, - indexPattern, - config.get(SORT_DEFAULT_ORDER_SETTING) - ) - ) - .setField('query', data.query.queryString.getQuery() || null) - .setField('filter', filterManager.getFilters()); + updateSearchSource($scope.searchSource, { + indexPattern: $scope.indexPattern, + services, + sort: $scope.state.sort, + }); return Promise.resolve(); }; @@ -1044,11 +763,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise const columns = columnActions.moveColumn($scope.state.columns, columnName, newIndex); setAppState({ columns }); }; - - $scope.scrollToTop = function () { - $window.scrollTo(0, 0); - }; - async function setupVisualization() { // If no timefield has been specified we don't create a histogram of messages if (!getTimeField()) return; @@ -1085,62 +799,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise }); } - function getIndexPatternWarning(index) { - return i18n.translate('discover.valueIsNotConfiguredIndexPatternIDWarningTitle', { - defaultMessage: '{stateVal} is not a configured index pattern ID', - values: { - stateVal: `"${index}"`, - }, - }); - } - - function resolveIndexPatternLoading() { - const { - loaded: loadedIndexPattern, - stateVal, - stateValFound, - } = $route.current.locals.savedObjects.ip; - - const ownIndexPattern = $scope.searchSource.getOwnField('index'); - - if (ownIndexPattern && !stateVal) { - return ownIndexPattern; - } - - if (stateVal && !stateValFound) { - const warningTitle = getIndexPatternWarning(); - - if (ownIndexPattern) { - toastNotifications.addWarning({ - title: warningTitle, - text: i18n.translate('discover.showingSavedIndexPatternWarningDescription', { - defaultMessage: - 'Showing the saved index pattern: "{ownIndexPatternTitle}" ({ownIndexPatternId})', - values: { - ownIndexPatternTitle: ownIndexPattern.title, - ownIndexPatternId: ownIndexPattern.id, - }, - }), - }); - return ownIndexPattern; - } - - toastNotifications.addWarning({ - title: warningTitle, - text: i18n.translate('discover.showingDefaultIndexPatternWarningDescription', { - defaultMessage: - 'Showing the default index pattern: "{loadedIndexPatternTitle}" ({loadedIndexPatternId})', - values: { - loadedIndexPatternTitle: loadedIndexPattern.title, - loadedIndexPatternId: loadedIndexPattern.id, - }, - }), - }); - } - - return loadedIndexPattern; - } - addHelpMenuToAppChrome(chrome); init(); diff --git a/src/plugins/discover/public/application/angular/discover_state.test.ts b/src/plugins/discover/public/application/angular/discover_state.test.ts index b7b36ca960167..2914ce8f17a09 100644 --- a/src/plugins/discover/public/application/angular/discover_state.test.ts +++ b/src/plugins/discover/public/application/angular/discover_state.test.ts @@ -29,7 +29,7 @@ describe('Test discover state', () => { history = createBrowserHistory(); history.push('/'); state = getState({ - defaultAppState: { index: 'test' }, + getStateDefaults: () => ({ index: 'test' }), history, }); await state.replaceUrlAppState({}); @@ -84,7 +84,7 @@ describe('Test discover state with legacy migration', () => { "/#?_a=(query:(query_string:(analyze_wildcard:!t,query:'type:nice%20name:%22yeah%22')))" ); state = getState({ - defaultAppState: { index: 'test' }, + getStateDefaults: () => ({ index: 'test' }), history, }); expect(state.appStateContainer.getState()).toMatchInlineSnapshot(` diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts index 5ddb6a92b5fd4..3c6ef1d3e4334 100644 --- a/src/plugins/discover/public/application/angular/discover_state.ts +++ b/src/plugins/discover/public/application/angular/discover_state.ts @@ -65,7 +65,7 @@ interface GetStateParams { /** * Default state used for merging with with URL state to get the initial state */ - defaultAppState?: AppState; + getStateDefaults?: () => AppState; /** * Determins the use of long vs. short/hashed urls */ @@ -123,7 +123,11 @@ export interface GetStateReturn { /** * Returns whether the current app state is different to the initial state */ - isAppStateDirty: () => void; + isAppStateDirty: () => boolean; + /** + * Reset AppState to default, discarding all changes + */ + resetAppState: () => void; } const APP_STATE_URL_KEY = '_a'; @@ -132,11 +136,12 @@ const APP_STATE_URL_KEY = '_a'; * Used to sync URL with UI state */ export function getState({ - defaultAppState = {}, + getStateDefaults, storeInSessionStorage = false, history, toasts, }: GetStateParams): GetStateReturn { + const defaultAppState = getStateDefaults ? getStateDefaults() : {}; const stateStorage = createKbnUrlStateStorage({ useHash: storeInSessionStorage, history, @@ -185,6 +190,10 @@ export function getState({ resetInitialAppState: () => { initialAppState = appStateContainer.getState(); }, + resetAppState: () => { + const defaultState = getStateDefaults ? getStateDefaults() : {}; + setState(appStateContainerModified, defaultState); + }, getPreviousAppState: () => previousAppState, flushToUrl: () => stateStorage.flush(), isAppStateDirty: () => !isEqualState(initialAppState, appStateContainer.getState()), 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 4a539b618f817..e44c05b3a88a9 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/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap similarity index 96% rename from src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap rename to src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap index 42cd8613b1de0..2c2674b158bfc 100644 --- a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap +++ b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap @@ -3,7 +3,7 @@ exports[`render 1`] = ` { + const topNavLinks = getTopNavLinks({ + getFieldCounts: jest.fn(), + indexPattern: indexPatternMock, + inspectorAdapters: inspectorPluginMock, + navigateTo: jest.fn(), + savedSearch: savedSearchMock, + services, + state, + }); + expect(topNavLinks).toMatchInlineSnapshot(` + Array [ + Object { + "description": "New Search", + "id": "new", + "label": "New", + "run": [Function], + "testId": "discoverNewButton", + }, + Object { + "description": "Save Search", + "id": "save", + "label": "Save", + "run": [Function], + "testId": "discoverSaveButton", + }, + Object { + "description": "Open Saved Search", + "id": "open", + "label": "Open", + "run": [Function], + "testId": "discoverOpenButton", + }, + Object { + "description": "Share Search", + "id": "share", + "label": "Share", + "run": [Function], + "testId": "shareTopNavButton", + }, + Object { + "description": "Open Inspector for search", + "id": "inspect", + "label": "Inspect", + "run": [Function], + "testId": "openInspectorButton", + }, + ] + `); +}); diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts new file mode 100644 index 0000000000000..62542e9ace4dd --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts @@ -0,0 +1,148 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { i18n } from '@kbn/i18n'; +import { showOpenSearchPanel } from './show_open_search_panel'; +import { getSharingData } from '../../helpers/get_sharing_data'; +import { unhashUrl } from '../../../../../kibana_utils/public'; +import { DiscoverServices } from '../../../build_services'; +import { Adapters } from '../../../../../inspector/common/adapters'; +import { SavedSearch } from '../../../saved_searches'; +import { onSaveSearch } from './on_save_search'; +import { GetStateReturn } from '../../angular/discover_state'; +import { IndexPattern } from '../../../kibana_services'; + +/** + * Helper function to build the top nav links + */ +export const getTopNavLinks = ({ + getFieldCounts, + indexPattern, + inspectorAdapters, + navigateTo, + savedSearch, + services, + state, +}: { + getFieldCounts: () => Promise>; + indexPattern: IndexPattern; + inspectorAdapters: Adapters; + navigateTo: (url: string) => void; + savedSearch: SavedSearch; + services: DiscoverServices; + state: GetStateReturn; +}) => { + const newSearch = { + id: 'new', + label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { + defaultMessage: 'New', + }), + description: i18n.translate('discover.localMenu.newSearchDescription', { + defaultMessage: 'New Search', + }), + run: () => navigateTo('/'), + testId: 'discoverNewButton', + }; + + const saveSearch = { + id: 'save', + label: i18n.translate('discover.localMenu.saveTitle', { + defaultMessage: 'Save', + }), + description: i18n.translate('discover.localMenu.saveSearchDescription', { + defaultMessage: 'Save Search', + }), + testId: 'discoverSaveButton', + run: () => onSaveSearch({ savedSearch, services, indexPattern, navigateTo, state }), + }; + + const openSearch = { + id: 'open', + label: i18n.translate('discover.localMenu.openTitle', { + defaultMessage: 'Open', + }), + description: i18n.translate('discover.localMenu.openSavedSearchDescription', { + defaultMessage: 'Open Saved Search', + }), + testId: 'discoverOpenButton', + run: () => + showOpenSearchPanel({ + makeUrl: (searchId) => `#/view/${encodeURIComponent(searchId)}`, + I18nContext: services.core.i18n.Context, + }), + }; + + const shareSearch = { + id: 'share', + label: i18n.translate('discover.localMenu.shareTitle', { + defaultMessage: 'Share', + }), + description: i18n.translate('discover.localMenu.shareSearchDescription', { + defaultMessage: 'Share Search', + }), + testId: 'shareTopNavButton', + run: async (anchorElement: HTMLElement) => { + if (!services.share) { + return; + } + const sharingData = await getSharingData( + savedSearch.searchSource, + state.appStateContainer.getState(), + services.uiSettings, + getFieldCounts + ); + services.share.toggleShareContextMenu({ + anchorElement, + allowEmbed: false, + allowShortUrl: !!services.capabilities.discover.createShortUrl, + shareableUrl: unhashUrl(window.location.href), + objectId: savedSearch.id, + objectType: 'search', + sharingData: { + ...sharingData, + title: savedSearch.title, + }, + isDirty: !savedSearch.id || state.isAppStateDirty(), + }); + }, + }; + + const inspectSearch = { + id: 'inspect', + label: i18n.translate('discover.localMenu.inspectTitle', { + defaultMessage: 'Inspect', + }), + description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', { + defaultMessage: 'Open Inspector for search', + }), + testId: 'openInspectorButton', + run: () => { + services.inspector.open(inspectorAdapters, { + title: savedSearch.title, + }); + }, + }; + + return [ + newSearch, + ...(services.capabilities.discover.save ? [saveSearch] : []), + openSearch, + shareSearch, + inspectSearch, + ]; +}; diff --git a/src/plugins/discover/public/application/components/top_nav/on_save_search.test.tsx b/src/plugins/discover/public/application/components/top_nav/on_save_search.test.tsx new file mode 100644 index 0000000000000..b96af355fafd0 --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/on_save_search.test.tsx @@ -0,0 +1,47 @@ +/* + * 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 { showSaveModal } from '../../../../../saved_objects/public'; +jest.mock('../../../../../saved_objects/public'); + +import { onSaveSearch } from './on_save_search'; +import { indexPatternMock } from '../../../__mocks__/index_pattern'; +import { savedSearchMock } from '../../../__mocks__/saved_search'; +import { DiscoverServices } from '../../../build_services'; +import { GetStateReturn } from '../../angular/discover_state'; +import { i18nServiceMock } from '../../../../../../core/public/mocks'; + +test('onSaveSearch', async () => { + const serviceMock = ({ + core: { + i18n: i18nServiceMock.create(), + }, + } as unknown) as DiscoverServices; + const stateMock = ({} as unknown) as GetStateReturn; + + await onSaveSearch({ + indexPattern: indexPatternMock, + navigateTo: jest.fn(), + savedSearch: savedSearchMock, + services: serviceMock, + state: stateMock, + }); + + expect(showSaveModal).toHaveBeenCalled(); +}); diff --git a/src/plugins/discover/public/application/components/top_nav/on_save_search.tsx b/src/plugins/discover/public/application/components/top_nav/on_save_search.tsx new file mode 100644 index 0000000000000..c3343968a4685 --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/on_save_search.tsx @@ -0,0 +1,158 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { SavedObjectSaveModal, showSaveModal } from '../../../../../saved_objects/public'; +import { SavedSearch } from '../../../saved_searches'; +import { IndexPattern } from '../../../../../data/common/index_patterns/index_patterns'; +import { DiscoverServices } from '../../../build_services'; +import { GetStateReturn } from '../../angular/discover_state'; +import { setBreadcrumbsTitle } from '../../helpers/breadcrumbs'; +import { persistSavedSearch } from '../../helpers/persist_saved_search'; + +async function saveDataSource({ + indexPattern, + navigateTo, + savedSearch, + saveOptions, + services, + state, +}: { + indexPattern: IndexPattern; + navigateTo: (url: string) => void; + savedSearch: SavedSearch; + saveOptions: { + confirmOverwrite: boolean; + isTitleDuplicateConfirmed: boolean; + onTitleDuplicate: () => void; + }; + services: DiscoverServices; + state: GetStateReturn; +}) { + const prevSavedSearchId = savedSearch.id; + function onSuccess(id: string) { + if (id) { + services.toastNotifications.addSuccess({ + title: i18n.translate('discover.notifications.savedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was saved`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + 'data-test-subj': 'saveSearchSuccess', + }); + + if (savedSearch.id !== prevSavedSearchId) { + navigateTo(`/view/${encodeURIComponent(savedSearch.id)}`); + } else { + // Update defaults so that "reload saved query" functions correctly + state.resetAppState(); + services.chrome.docTitle.change(savedSearch.lastSavedTitle!); + setBreadcrumbsTitle(savedSearch, services.chrome); + } + } + } + + function onError(error: Error) { + services.toastNotifications.addDanger({ + title: i18n.translate('discover.notifications.notSavedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was not saved.`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + text: error.message, + }); + } + return persistSavedSearch(savedSearch, { + indexPattern, + onError, + onSuccess, + saveOptions, + services, + state: state.appStateContainer.getState(), + }); +} + +export async function onSaveSearch({ + indexPattern, + navigateTo, + savedSearch, + services, + state, +}: { + indexPattern: IndexPattern; + navigateTo: (path: string) => void; + savedSearch: SavedSearch; + services: DiscoverServices; + state: GetStateReturn; +}) { + const onSave = async ({ + newTitle, + newCopyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }: { + newTitle: string; + newCopyOnSave: boolean; + isTitleDuplicateConfirmed: boolean; + onTitleDuplicate: () => void; + }) => { + const currentTitle = savedSearch.title; + savedSearch.title = newTitle; + savedSearch.copyOnSave = newCopyOnSave; + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + const response = await saveDataSource({ + indexPattern, + saveOptions, + services, + navigateTo, + savedSearch, + state, + }); + // If the save wasn't successful, put the original values back. + if (!response.id || response.error) { + savedSearch.title = currentTitle; + } else { + state.resetInitialAppState(); + } + return response; + }; + + const saveModal = ( + {}} + title={savedSearch.title} + showCopyOnSave={!!savedSearch.id} + objectType="search" + description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { + defaultMessage: + 'Save your Discover search so you can use it in visualizations and dashboards', + })} + showDescription={false} + /> + ); + showSaveModal(saveModal, services.core.i18n.Context); +} diff --git a/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.js b/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.tsx similarity index 89% rename from src/plugins/discover/public/application/components/top_nav/open_search_panel.test.js rename to src/plugins/discover/public/application/components/top_nav/open_search_panel.test.tsx index 50ab02c8e273d..4b06964c7bc39 100644 --- a/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.js +++ b/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.tsx @@ -24,7 +24,7 @@ jest.mock('../../../kibana_services', () => { return { getServices: () => ({ core: { uiSettings: {}, savedObjects: {} }, - addBasePath: (path) => path, + addBasePath: (path: string) => path, }), }; }); @@ -32,6 +32,6 @@ jest.mock('../../../kibana_services', () => { import { OpenSearchPanel } from './open_search_panel'; test('render', () => { - const component = shallow( {}} makeUrl={() => {}} />); + const component = shallow(); expect(component).toMatchSnapshot(); }); diff --git a/src/plugins/discover/public/application/components/top_nav/open_search_panel.js b/src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx similarity index 94% rename from src/plugins/discover/public/application/components/top_nav/open_search_panel.js rename to src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx index 9a6840c29bf1c..62441f7d827d9 100644 --- a/src/plugins/discover/public/application/components/top_nav/open_search_panel.js +++ b/src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx @@ -16,9 +16,7 @@ * specific language governing permissions and limitations * under the License. */ - import React from 'react'; -import PropTypes from 'prop-types'; import rison from 'rison-node'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -37,7 +35,12 @@ import { getServices } from '../../../kibana_services'; const SEARCH_OBJECT_TYPE = 'search'; -export function OpenSearchPanel(props) { +interface OpenSearchPanelProps { + onClose: () => void; + makeUrl: (id: string) => string; +} + +export function OpenSearchPanel(props: OpenSearchPanelProps) { const { core: { uiSettings, savedObjects }, addBasePath, @@ -102,8 +105,3 @@ export function OpenSearchPanel(props) { ); } - -OpenSearchPanel.propTypes = { - onClose: PropTypes.func.isRequired, - makeUrl: PropTypes.func.isRequired, -}; diff --git a/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.js b/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx similarity index 87% rename from src/plugins/discover/public/application/components/top_nav/show_open_search_panel.js rename to src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx index e40d700b48885..d9a5cdcb063d3 100644 --- a/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.js +++ b/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx @@ -19,11 +19,18 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { I18nStart } from 'kibana/public'; import { OpenSearchPanel } from './open_search_panel'; let isOpen = false; -export function showOpenSearchPanel({ makeUrl, I18nContext }) { +export function showOpenSearchPanel({ + makeUrl, + I18nContext, +}: { + makeUrl: (path: string) => string; + I18nContext: I18nStart['Context']; +}) { if (isOpen) { return; } diff --git a/src/plugins/discover/public/application/helpers/breadcrumbs.ts b/src/plugins/discover/public/application/helpers/breadcrumbs.ts index 17492b02f7eab..96a9f546a0636 100644 --- a/src/plugins/discover/public/application/helpers/breadcrumbs.ts +++ b/src/plugins/discover/public/application/helpers/breadcrumbs.ts @@ -17,7 +17,9 @@ * under the License. */ +import { ChromeStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; +import { SavedSearch } from '../../saved_searches'; export function getRootBreadcrumbs() { return [ @@ -38,3 +40,29 @@ export function getSavedSearchBreadcrumbs($route: any) { }, ]; } + +/** + * Helper function to set the Discover's breadcrumb + * if there's an active savedSearch, its title is appended + */ +export function setBreadcrumbsTitle(savedSearch: SavedSearch, chrome: ChromeStart) { + const discoverBreadcrumbsTitle = i18n.translate('discover.discoverBreadcrumbTitle', { + defaultMessage: 'Discover', + }); + + if (savedSearch.id && savedSearch.title) { + chrome.setBreadcrumbs([ + { + text: discoverBreadcrumbsTitle, + href: '#/', + }, + { text: savedSearch.title }, + ]); + } else { + chrome.setBreadcrumbs([ + { + text: discoverBreadcrumbsTitle, + }, + ]); + } +} diff --git a/src/plugins/discover/public/application/helpers/calc_field_counts.test.ts b/src/plugins/discover/public/application/helpers/calc_field_counts.test.ts new file mode 100644 index 0000000000000..ce3319bf8a667 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/calc_field_counts.test.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { calcFieldCounts } from './calc_field_counts'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; + +describe('calcFieldCounts', () => { + test('returns valid field count data', async () => { + const rows = [ + { _id: 1, _source: { message: 'test1', bytes: 20 } }, + { _id: 2, _source: { name: 'test2', extension: 'jpg' } }, + ]; + const result = calcFieldCounts({}, rows, indexPatternMock); + expect(result).toMatchInlineSnapshot(` + Object { + "_index": 2, + "_score": 2, + "bytes": 1, + "extension": 1, + "message": 1, + "name": 1, + } + `); + }); + test('updates field count data', async () => { + const rows = [ + { _id: 1, _source: { message: 'test1', bytes: 20 } }, + { _id: 2, _source: { name: 'test2', extension: 'jpg' } }, + ]; + const result = calcFieldCounts({ message: 2 }, rows, indexPatternMock); + expect(result).toMatchInlineSnapshot(` + Object { + "_index": 2, + "_score": 2, + "bytes": 1, + "extension": 1, + "message": 3, + "name": 1, + } + `); + }); +}); diff --git a/packages/kbn-es-archiver/src/lib/streams/list_stream.ts b/src/plugins/discover/public/application/helpers/calc_field_counts.ts similarity index 58% rename from packages/kbn-es-archiver/src/lib/streams/list_stream.ts rename to src/plugins/discover/public/application/helpers/calc_field_counts.ts index c061b969b3c09..02c0299995e19 100644 --- a/packages/kbn-es-archiver/src/lib/streams/list_stream.ts +++ b/src/plugins/discover/public/application/helpers/calc_field_counts.ts @@ -16,26 +16,23 @@ * specific language governing permissions and limitations * under the License. */ - -import { Readable } from 'stream'; +import { IndexPattern } from '../../kibana_services'; /** - * Create a Readable stream that provides the items - * from a list as objects to subscribers + * This function is recording stats of the available fields, for usage in sidebar and sharing + * Note that this values aren't displayed, but used for internal calculations */ -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); - }); +export function calcFieldCounts( + counts = {} as Record, + rows: Array>, + indexPattern: IndexPattern +) { + for (const hit of rows) { + const fields = Object.keys(indexPattern.flattenHit(hit)); + for (const fieldName of fields) { + counts[fieldName] = (counts[fieldName] || 0) + 1; + } + } - if (!queue.length) { - this.push(null); - } - }, - }); + return counts; } diff --git a/src/plugins/discover/public/application/helpers/get_index_pattern_id.ts b/src/plugins/discover/public/application/helpers/get_index_pattern_id.ts deleted file mode 100644 index 601f892e3c56a..0000000000000 --- a/src/plugins/discover/public/application/helpers/get_index_pattern_id.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 { IIndexPattern } from '../../../../data/common/index_patterns'; - -export function findIndexPatternById( - indexPatterns: IIndexPattern[], - id: string -): IIndexPattern | undefined { - if (!Array.isArray(indexPatterns) || !id) { - return; - } - return indexPatterns.find((o) => o.id === id); -} - -/** - * Checks if the given defaultIndex exists and returns - * the first available index pattern id if not - */ -export function getFallbackIndexPatternId( - indexPatterns: IIndexPattern[], - defaultIndex: string = '' -): string { - if (defaultIndex && findIndexPatternById(indexPatterns, defaultIndex)) { - return defaultIndex; - } - return !indexPatterns || !indexPatterns.length || !indexPatterns[0].id ? '' : indexPatterns[0].id; -} - -/** - * A given index pattern id is checked for existence and a fallback is provided if it doesn't exist - * The provided defaultIndex is usually configured in Advanced Settings, if it's also invalid - * the first entry of the given list of Indexpatterns is used - */ -export function getIndexPatternId( - id: string = '', - indexPatterns: IIndexPattern[], - defaultIndex: string = '' -): string { - if (!id || !findIndexPatternById(indexPatterns, id)) { - return getFallbackIndexPatternId(indexPatterns, defaultIndex); - } - return id; -} diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts new file mode 100644 index 0000000000000..8ce9789d1dc84 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { getSharingData } from './get_sharing_data'; +import { IUiSettingsClient } from 'kibana/public'; +import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; + +describe('getSharingData', () => { + test('returns valid data for sharing', async () => { + const searchSourceMock = createSearchSourceMock({ index: indexPatternMock }); + const result = await getSharingData( + searchSourceMock, + { columns: [] }, + ({ + get: () => { + return false; + }, + } as unknown) as IUiSettingsClient, + () => Promise.resolve({}) + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [], + "indexPatternId": "the-index-pattern-id", + "metaFields": Array [ + "_index", + "_score", + ], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [], + }, + "docvalue_fields": Array [], + "query": Object { + "bool": Object { + "filter": Array [], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [], + "stored_fields": Array [], + }, + "index": "the-index-pattern-title", + }, + } + `); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.ts new file mode 100644 index 0000000000000..0edaa356cba7d --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.ts @@ -0,0 +1,88 @@ +/* + * 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 { IUiSettingsClient } from 'kibana/public'; +import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; +import { getSortForSearchSource } from '../angular/doc_table'; +import { SearchSource } from '../../../../data/common'; +import { AppState } from '../angular/discover_state'; +import { SortOrder } from '../../saved_searches/types'; + +const getSharingDataFields = async ( + getFieldCounts: () => Promise>, + selectedFields: string[], + timeFieldName: string, + hideTimeColumn: boolean +) => { + if (selectedFields.length === 1 && selectedFields[0] === '_source') { + const fieldCounts = await getFieldCounts(); + return { + searchFields: undefined, + selectFields: Object.keys(fieldCounts).sort(), + }; + } + + const fields = + timeFieldName && !hideTimeColumn ? [timeFieldName, ...selectedFields] : selectedFields; + return { + searchFields: fields, + selectFields: fields, + }; +}; + +/** + * Preparing data to share the current state as link or CSV/Report + */ +export async function getSharingData( + currentSearchSource: SearchSource, + state: AppState, + config: IUiSettingsClient, + getFieldCounts: () => Promise> +) { + const searchSource = currentSearchSource.createCopy(); + const index = searchSource.getField('index')!; + + const { searchFields, selectFields } = await getSharingDataFields( + getFieldCounts, + state.columns || [], + index.timeFieldName || '', + config.get(DOC_HIDE_TIME_COLUMN_SETTING) + ); + searchSource.setField('fields', searchFields); + searchSource.setField( + 'sort', + getSortForSearchSource(state.sort as SortOrder[], index, config.get(SORT_DEFAULT_ORDER_SETTING)) + ); + searchSource.removeField('highlight'); + searchSource.removeField('highlightAll'); + searchSource.removeField('aggs'); + searchSource.removeField('size'); + + const body = await searchSource.getSearchRequestBody(); + + return { + searchRequest: { + index: index.title, + body, + }, + fields: selectFields, + metaFields: index.metaFields, + conflictedTypesFields: index.fields.filter((f) => f.type === 'conflict').map((f) => f.name), + indexPatternId: index.id, + }; +} diff --git a/src/plugins/discover/public/application/helpers/persist_saved_search.ts b/src/plugins/discover/public/application/helpers/persist_saved_search.ts new file mode 100644 index 0000000000000..8e956eff598f3 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/persist_saved_search.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { updateSearchSource } from './update_search_source'; +import { IndexPattern } from '../../../../data/public'; +import { SavedSearch } from '../../saved_searches'; +import { AppState } from '../angular/discover_state'; +import { SortOrder } from '../../saved_searches/types'; +import { SavedObjectSaveOpts } from '../../../../saved_objects/public'; +import { DiscoverServices } from '../../build_services'; + +/** + * Helper function to update and persist the given savedSearch + */ +export async function persistSavedSearch( + savedSearch: SavedSearch, + { + indexPattern, + onError, + onSuccess, + services, + saveOptions, + state, + }: { + indexPattern: IndexPattern; + onError: (error: Error, savedSearch: SavedSearch) => void; + onSuccess: (id: string) => void; + saveOptions: SavedObjectSaveOpts; + services: DiscoverServices; + state: AppState; + } +) { + updateSearchSource(savedSearch.searchSource, { + indexPattern, + services, + sort: state.sort as SortOrder[], + }); + + savedSearch.columns = state.columns || []; + savedSearch.sort = (state.sort as SortOrder[]) || []; + + try { + const id = await savedSearch.save(saveOptions); + onSuccess(id); + return { id }; + } catch (saveError) { + onError(saveError, savedSearch); + return { error: saveError }; + } +} diff --git a/src/plugins/discover/public/application/helpers/resolve_index_pattern.test.ts b/src/plugins/discover/public/application/helpers/resolve_index_pattern.test.ts new file mode 100644 index 0000000000000..826f738c381a4 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/resolve_index_pattern.test.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + loadIndexPattern, + getFallbackIndexPatternId, + IndexPatternSavedObject, +} from './resolve_index_pattern'; +import { indexPatternsMock } from '../../__mocks__/index_patterns'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; +import { configMock } from '../../__mocks__/config'; + +describe('Resolve index pattern tests', () => { + test('returns valid data for an existing index pattern', async () => { + const indexPatternId = 'the-index-pattern-id'; + const result = await loadIndexPattern(indexPatternId, indexPatternsMock, configMock); + expect(result.loaded).toEqual(indexPatternMock); + expect(result.stateValFound).toEqual(true); + expect(result.stateVal).toEqual(indexPatternId); + }); + test('returns fallback data for an invalid index pattern', async () => { + const indexPatternId = 'invalid-id'; + const result = await loadIndexPattern(indexPatternId, indexPatternsMock, configMock); + expect(result.loaded).toEqual(indexPatternMock); + expect(result.stateValFound).toBe(false); + expect(result.stateVal).toBe(indexPatternId); + }); + test('getFallbackIndexPatternId with an empty indexPatterns array', async () => { + const result = await getFallbackIndexPatternId([], ''); + expect(result).toBe(''); + }); + test('getFallbackIndexPatternId with an indexPatterns array', async () => { + const list = await indexPatternsMock.getCache(); + const result = await getFallbackIndexPatternId( + (list as unknown) as IndexPatternSavedObject[], + '' + ); + expect(result).toBe('the-index-pattern-id'); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/resolve_index_pattern.ts b/src/plugins/discover/public/application/helpers/resolve_index_pattern.ts new file mode 100644 index 0000000000000..61f7f087501ba --- /dev/null +++ b/src/plugins/discover/public/application/helpers/resolve_index_pattern.ts @@ -0,0 +1,158 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient, SavedObject, ToastsStart } from 'kibana/public'; +import { IndexPattern } from '../../kibana_services'; +import { IndexPatternsService, SearchSource } from '../../../../data/common'; + +export type IndexPatternSavedObject = SavedObject & { title: string }; + +interface IndexPatternData { + /** + * List of existing index patterns + */ + list: IndexPatternSavedObject[]; + /** + * Loaded index pattern (might be default index pattern if requested was not found) + */ + loaded: IndexPattern; + /** + * Id of the requested index pattern + */ + stateVal: string; + /** + * Determines if requested index pattern was found + */ + stateValFound: boolean; +} + +export function findIndexPatternById( + indexPatterns: IndexPatternSavedObject[], + id: string +): IndexPatternSavedObject | undefined { + if (!Array.isArray(indexPatterns) || !id) { + return; + } + return indexPatterns.find((o) => o.id === id); +} + +/** + * Checks if the given defaultIndex exists and returns + * the first available index pattern id if not + */ +export function getFallbackIndexPatternId( + indexPatterns: IndexPatternSavedObject[], + defaultIndex: string = '' +): string { + if (defaultIndex && findIndexPatternById(indexPatterns, defaultIndex)) { + return defaultIndex; + } + return indexPatterns && indexPatterns[0]?.id ? indexPatterns[0].id : ''; +} + +/** + * A given index pattern id is checked for existence and a fallback is provided if it doesn't exist + * The provided defaultIndex is usually configured in Advanced Settings, if it's also invalid + * the first entry of the given list of Indexpatterns is used + */ +export function getIndexPatternId( + id: string = '', + indexPatterns: IndexPatternSavedObject[] = [], + defaultIndex: string = '' +): string { + if (!id || !findIndexPatternById(indexPatterns, id)) { + return getFallbackIndexPatternId(indexPatterns, defaultIndex); + } + return id; +} + +/** + * Function to load the given index pattern by id, providing a fallback if it doesn't exist + */ +export async function loadIndexPattern( + id: string, + indexPatterns: IndexPatternsService, + config: IUiSettingsClient +): Promise { + const indexPatternList = ((await indexPatterns.getCache()) as unknown) as IndexPatternSavedObject[]; + + const actualId = getIndexPatternId(id, indexPatternList, config.get('defaultIndex')); + return { + list: indexPatternList || [], + loaded: await indexPatterns.get(actualId), + stateVal: id, + stateValFound: !!id && actualId === id, + }; +} + +/** + * Function used in the discover controller to message the user about the state of the current + * index pattern + */ +export function resolveIndexPattern( + ip: IndexPatternData, + searchSource: SearchSource, + toastNotifications: ToastsStart +) { + const { loaded: loadedIndexPattern, stateVal, stateValFound } = ip; + + const ownIndexPattern = searchSource.getOwnField('index'); + + if (ownIndexPattern && !stateVal) { + return ownIndexPattern; + } + + if (stateVal && !stateValFound) { + const warningTitle = i18n.translate('discover.valueIsNotConfiguredIndexPatternIDWarningTitle', { + defaultMessage: '{stateVal} is not a configured index pattern ID', + values: { + stateVal: `"${stateVal}"`, + }, + }); + + if (ownIndexPattern) { + toastNotifications.addWarning({ + title: warningTitle, + text: i18n.translate('discover.showingSavedIndexPatternWarningDescription', { + defaultMessage: + 'Showing the saved index pattern: "{ownIndexPatternTitle}" ({ownIndexPatternId})', + values: { + ownIndexPatternTitle: ownIndexPattern.title, + ownIndexPatternId: ownIndexPattern.id, + }, + }), + }); + return ownIndexPattern; + } + + toastNotifications.addWarning({ + title: warningTitle, + text: i18n.translate('discover.showingDefaultIndexPatternWarningDescription', { + defaultMessage: + 'Showing the default index pattern: "{loadedIndexPatternTitle}" ({loadedIndexPatternId})', + values: { + loadedIndexPatternTitle: loadedIndexPattern.title, + loadedIndexPatternId: loadedIndexPattern.id, + }, + }), + }); + } + + return loadedIndexPattern; +} diff --git a/src/plugins/discover/public/application/helpers/update_search_source.test.ts b/src/plugins/discover/public/application/helpers/update_search_source.test.ts new file mode 100644 index 0000000000000..91832325432ef --- /dev/null +++ b/src/plugins/discover/public/application/helpers/update_search_source.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { updateSearchSource } from './update_search_source'; +import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; +import { IUiSettingsClient } from 'kibana/public'; +import { DiscoverServices } from '../../build_services'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { SAMPLE_SIZE_SETTING } from '../../../common'; +import { SortOrder } from '../../saved_searches/types'; + +describe('updateSearchSource', () => { + test('updates a given search source', async () => { + const searchSourceMock = createSearchSourceMock({}); + const sampleSize = 250; + const result = updateSearchSource(searchSourceMock, { + indexPattern: indexPatternMock, + services: ({ + data: dataPluginMock.createStartContract(), + uiSettings: ({ + get: (key: string) => { + if (key === SAMPLE_SIZE_SETTING) { + return sampleSize; + } + return false; + }, + } as unknown) as IUiSettingsClient, + } as unknown) as DiscoverServices, + sort: [] as SortOrder[], + }); + expect(result.getField('index')).toEqual(indexPatternMock); + expect(result.getField('size')).toEqual(sampleSize); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/update_search_source.ts b/src/plugins/discover/public/application/helpers/update_search_source.ts new file mode 100644 index 0000000000000..324dc8a48457a --- /dev/null +++ b/src/plugins/discover/public/application/helpers/update_search_source.ts @@ -0,0 +1,54 @@ +/* + * 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 { getSortForSearchSource } from '../angular/doc_table'; +import { SAMPLE_SIZE_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; +import { IndexPattern, ISearchSource } from '../../../../data/common/'; +import { SortOrder } from '../../saved_searches/types'; +import { DiscoverServices } from '../../build_services'; + +/** + * Helper function to update the given searchSource before fetching/sharing/persisting + */ +export function updateSearchSource( + searchSource: ISearchSource, + { + indexPattern, + services, + sort, + }: { + indexPattern: IndexPattern; + services: DiscoverServices; + sort: SortOrder[]; + } +) { + const { uiSettings, data } = services; + const usedSort = getSortForSearchSource( + sort, + indexPattern, + uiSettings.get(SORT_DEFAULT_ORDER_SETTING) + ); + + searchSource + .setField('index', indexPattern) + .setField('size', uiSettings.get(SAMPLE_SIZE_SETTING)) + .setField('sort', usedSort) + .setField('query', data.query.queryString.getQuery() || null) + .setField('filter', data.query.filterManager.getFilters()); + return searchSource; +} diff --git a/src/plugins/discover/public/saved_searches/types.ts b/src/plugins/discover/public/saved_searches/types.ts index 13361cb647ddc..d5e5dd765a364 100644 --- a/src/plugins/discover/public/saved_searches/types.ts +++ b/src/plugins/discover/public/saved_searches/types.ts @@ -17,18 +17,21 @@ * under the License. */ -import { ISearchSource } from '../../../data/public'; +import { SearchSource } from '../../../data/public'; +import { SavedObjectSaveOpts } from '../../../saved_objects/public'; export type SortOrder = [string, string]; export interface SavedSearch { readonly id: string; title: string; - searchSource: ISearchSource; + searchSource: SearchSource; description?: string; columns: string[]; sort: SortOrder[]; destroy: () => void; + save: (saveOptions: SavedObjectSaveOpts) => Promise; lastSavedTitle?: string; + copyOnSave?: boolean; } export interface SavedSearchLoader { get: (id: string) => Promise; diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 292d7d3bf7a1e..1426ade147d30 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 d8b4f4801bba3..d36954528dbf0 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 893b6b04e50bc..b6a7137c1e421 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/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index f3f3682404e32..023cb3d19b632 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -12,6 +12,7 @@ import { ApiResponse as ApiResponse_2 } from '@elastic/elasticsearch'; import { ApplicationStart as ApplicationStart_2 } from 'kibana/public'; import { Assign } from '@kbn/utility-types'; import { BehaviorSubject } from 'rxjs'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; import Boom from '@hapi/boom'; import { CoreSetup as CoreSetup_2 } from 'src/core/public'; import { CoreSetup as CoreSetup_3 } from 'kibana/public'; diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/checkbox_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/checkbox_field.tsx index b80d6caf54f4f..2a7087b5b2806 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/checkbox_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/fields/checkbox_field.tsx @@ -30,7 +30,7 @@ interface Props { [key: string]: any; } -export const CheckBoxField = ({ field, euiFieldProps = {}, ...rest }: Props) => { +export const CheckBoxField = ({ field, euiFieldProps = {}, idAria, ...rest }: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( @@ -39,7 +39,7 @@ export const CheckBoxField = ({ field, euiFieldProps = {}, ...rest }: Props) => error={errorMessage} isInvalid={isInvalid} fullWidth - describedByIds={rest.idAria ? [rest.idAria] : undefined} + describedByIds={idAria ? [idAria] : undefined} {...rest} > { +export const ComboBoxField = ({ field, euiFieldProps = {}, idAria, ...rest }: Props) => { // Errors for the comboBox value (the "array") const errorMessageField = field.getErrorsMessages(); @@ -87,7 +87,7 @@ export const ComboBoxField = ({ field, euiFieldProps = {}, ...rest }: Props) => error={errorMessage} isInvalid={isInvalid} fullWidth - describedByIds={rest.idAria ? [rest.idAria] : undefined} + describedByIds={idAria ? [idAria] : undefined} {...rest} > { +export const MultiSelectField = ({ field, euiFieldProps = {}, idAria, ...rest }: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( @@ -39,7 +39,7 @@ export const MultiSelectField = ({ field, euiFieldProps = {}, ...rest }: Props) error={errorMessage} isInvalid={isInvalid} fullWidth - describedByIds={rest.idAria ? [rest.idAria] : undefined} + describedByIds={idAria ? [idAria] : undefined} {...rest} > { +export const NumericField = ({ field, euiFieldProps = {}, idAria, ...rest }: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( @@ -39,7 +39,7 @@ export const NumericField = ({ field, euiFieldProps = {}, ...rest }: Props) => { error={errorMessage} isInvalid={isInvalid} fullWidth - describedByIds={rest.idAria ? [rest.idAria] : undefined} + describedByIds={idAria ? [idAria] : undefined} {...rest} > { +export const RadioGroupField = ({ field, euiFieldProps = {}, idAria, ...rest }: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( @@ -39,7 +39,7 @@ export const RadioGroupField = ({ field, euiFieldProps = {}, ...rest }: Props) = error={errorMessage} isInvalid={isInvalid} fullWidth - describedByIds={rest.idAria ? [rest.idAria] : undefined} + describedByIds={idAria ? [idAria] : undefined} {...rest} > { +export const RangeField = ({ field, euiFieldProps = {}, idAria, ...rest }: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const { onChange: onFieldChange } = field; @@ -50,7 +50,7 @@ export const RangeField = ({ field, euiFieldProps = {}, ...rest }: Props) => { error={errorMessage} isInvalid={isInvalid} fullWidth - describedByIds={rest.idAria ? [rest.idAria] : undefined} + describedByIds={idAria ? [idAria] : undefined} {...rest} > { +export const SelectField = ({ field, euiFieldProps, idAria, ...rest }: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( @@ -44,7 +44,7 @@ export const SelectField = ({ field, euiFieldProps, ...rest }: Props) => { error={errorMessage} isInvalid={isInvalid} fullWidth - describedByIds={rest.idAria ? [rest.idAria] : undefined} + describedByIds={idAria ? [idAria] : undefined} {...rest} > { +export const SuperSelectField = ({ + field, + euiFieldProps = { options: [] }, + idAria, + ...rest +}: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( @@ -42,7 +47,7 @@ export const SuperSelectField = ({ field, euiFieldProps = { options: [] }, ...re error={errorMessage} isInvalid={isInvalid} fullWidth - describedByIds={rest.idAria ? [rest.idAria] : undefined} + describedByIds={idAria ? [idAria] : undefined} {...rest} > { +export const TextAreaField = ({ field, euiFieldProps = {}, idAria, ...rest }: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( @@ -39,7 +39,7 @@ export const TextAreaField = ({ field, euiFieldProps = {}, ...rest }: Props) => error={errorMessage} isInvalid={isInvalid} fullWidth - describedByIds={rest.idAria ? [rest.idAria] : undefined} + describedByIds={idAria ? [idAria] : undefined} {...rest} > { +export const TextField = ({ field, euiFieldProps = {}, idAria, ...rest }: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( @@ -39,7 +39,7 @@ export const TextField = ({ field, euiFieldProps = {}, ...rest }: Props) => { error={errorMessage} isInvalid={isInvalid} fullWidth - describedByIds={rest.idAria ? [rest.idAria] : undefined} + describedByIds={idAria ? [idAria] : undefined} {...rest} > { +export const ToggleField = ({ field, euiFieldProps = {}, idAria, ...rest }: Props) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); // Shim for sufficient overlap between EuiSwitchEvent and FieldHook[onChange] event @@ -46,7 +46,7 @@ export const ToggleField = ({ field, euiFieldProps = {}, ...rest }: Props) => { error={errorMessage} isInvalid={isInvalid} fullWidth - describedByIds={rest.idAria ? [rest.idAria] : undefined} + describedByIds={idAria ? [idAria] : undefined} {...rest} > ( 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 0ea3d72e75609..dd3124c7d17ee 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 b94d9a305121f..2976697e0299f 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 bf8b442769563..598b614a326a9 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 91c482621de36..983a344c0e1a1 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 17f8e6255f6bb..2a73cd6e208d1 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 97a37d49147ec..c44683f6779c0 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 924f8d4830f73..4390033b5be60 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 4af36fea169a1..5bae985699476 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 e5b499206ebdd..33ff759faa3b1 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 a38f3cbd8fe81..5a202bff53b64 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 9ff8997f637e5..a262bfa708d0a 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 583dca1dbf036..d1ae9e7983bcd 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 e29922c2481c4..87a3fd8f5b499 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 71548cd5c7f51..81f7cc9326a77 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 950ecebeaadc7..9f98d9c21d233 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 0000000000000..6f40b894f85bc --- /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/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index fe4f3536ffed6..cda4ce36d4e23 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -29,7 +29,7 @@ import { import { Collector, CollectorOptions } from './collector'; import { UsageCollector, UsageCollectorOptions } from './usage_collector'; -type AnyCollector = Collector; +type AnyCollector = Collector; type AnyUsageCollector = UsageCollector; interface CollectorSetConfig { 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 1c5afd396c2c3..f7c74e324053e 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 bfc7abac02895..8546886e8350e 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 d4c37649f949d..71d4408ddc767 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 876784cc10140..69b55b5898257 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 9e32a6c4ae17c..7635e5214795a 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 a5425478e46ac..5512fdccd5e7e 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 8a9d4b83f94d2..f2030e4b83c19 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 7044bbf4e5831..535e8e8fe0f77 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 4f24bc273e265..bfcb5e8e15b9d 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 7f17a9c44298a..a90fa752ad7dc 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 85f31285df69b..e976519dfe635 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 c1d484765f4cb..f54d52620e67a 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 03da52b10f08b..180411dd13a3d 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 9742d817f7c0d..7893d5ba6d15e 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 59277257c0c94..25561cfe1dc04 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/data/common/search/es_search/to_snake_case.ts b/src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.ts similarity index 81% rename from src/plugins/data/common/search/es_search/to_snake_case.ts rename to src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.ts index b222a56fbf602..59a846aa66a07 100644 --- a/src/plugins/data/common/search/es_search/to_snake_case.ts +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.ts @@ -17,8 +17,7 @@ * under the License. */ -import { mapKeys, snakeCase } from 'lodash'; +import { Subject } from 'rxjs'; +import { PointerEvent } from '@elastic/charts'; -export function toSnakeCase(obj: Record): Record { - return mapKeys(obj, (value, key) => snakeCase(key)); -} +export const activeCursor$ = new Subject(); diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index 36624cfeea0c2..b13d82387a707 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -34,7 +34,7 @@ import { } from '@elastic/charts'; import { EuiIcon } from '@elastic/eui'; import { getTimezone } from '../../../lib/get_timezone'; -import { eventBus, ACTIVE_CURSOR } from '../../lib/active_cursor'; +import { activeCursor$ } from '../../lib/active_cursor'; import { getUISettings, getChartsSetup } from '../../../../services'; import { GRID_LINE_CONFIG, ICON_TYPES_MAP, STACKED_OPTIONS } from '../../constants'; import { AreaSeriesDecorator } from './decorators/area_decorator'; @@ -54,7 +54,7 @@ const generateAnnotationData = (values, formatter) => const decorateFormatter = (formatter) => ({ value }) => formatter(value); const handleCursorUpdate = (cursor) => { - eventBus.trigger(ACTIVE_CURSOR, cursor); + activeCursor$.next(cursor); }; export const TimeSeries = ({ @@ -73,16 +73,16 @@ export const TimeSeries = ({ const chartRef = useRef(); useEffect(() => { - const updateCursor = (_, cursor) => { + const updateCursor = (cursor) => { if (chartRef.current) { chartRef.current.dispatchExternalPointerEvent(cursor); } }; - eventBus.on(ACTIVE_CURSOR, updateCursor); + const subscription = activeCursor$.subscribe(updateCursor); return () => { - eventBus.off(ACTIVE_CURSOR, undefined, updateCursor); + subscription.unsubscribe(); }; }, []); 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 d11e9316c959b..1b2334c7dea94 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 82a2ef66cb1c0..9714b551ea82f 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 3791eb229db5b..eaaa5a9605b4b 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 0c3555adff1a6..0000000000000 --- 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 c021ba3cebc66..4384da58fb569 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 99bef2de6b72d..8810ccd406be4 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 1a1b12c651992..183ce50dd4a09 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/public/application/visualizations/lib/active_cursor.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts similarity index 76% rename from src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.js rename to src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts index 427ced4dc3f2a..54f3110b45808 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts @@ -17,9 +17,14 @@ * under the License. */ -// TODO: Remove bus when action/triggers are available with LegacyPluginApi or metric is converted to Embeddable -import $ from 'jquery'; +import { utc } from 'moment'; +import { ReqFacade, VisPayload } from '../../..'; -export const ACTIVE_CURSOR = 'ACTIVE_CURSOR'; +export const getTimerange = (req: ReqFacade) => { + const { min, max } = req.payload.timerange; -export const eventBus = $({}); + return { + from: utc(min), + to: utc(max), + }; +}; 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 4b611e46f1588..617a75f6bd59f 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 127687bf11fe9..cf02f601ea5ff 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 f1e58b8e4af2a..98c683bda1fdb 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 45cad1195fc78..aa95a79a62796 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 800145dac5468..023ee054a5e13 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 1ac4329b60f82..2154d2257815b 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 4a79ec2295877..c16e0fd3aaf15 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 7c0f43adf02f5..d891fc01bb266 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 f2b58822e68b6..f69473b613d1b 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 8f84023ce0c75..48714e83341ea 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 947e48ed2cab2..ba65e583cc094 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 ba2c09e93e7e6..fe6a8b537d64b 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 b219f84deef80..6cf165d124e26 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 1b14ffe34a947..ba08b18256dec 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 0c75e6ef1c5bd..6b2ef320d54b7 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 4c653ea49e7c6..3804b1407b086 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 531958d6b3db3..ec7bce254f586 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 2b3c415087ee1..bd3bdd1a01e9d 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 32574fb5b0a9c..46878ca82e45a 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 f970eddd645f5..c408ac140dd46 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 d5fb92f5c6a0c..de4855ba9aa2b 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 f1a5365e5ae74..144e63224533b 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 a65b0bcf7e2bb..ffa40c8c29980 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 58f7dd0df89e8..41b271054d59f 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/components/visualize_byvalue_editor.tsx b/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx index a63f597f10135..1c1eb9956a329 100644 --- a/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx +++ b/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx @@ -33,6 +33,7 @@ import { import { VisualizeServices } from '../types'; import { VisualizeEditorCommon } from './visualize_editor_common'; import { VisualizeAppProps } from '../app'; +import { VisualizeConstants } from '../..'; export const VisualizeByValueEditor = ({ onAppLeave }: VisualizeAppProps) => { const [originatingApp, setOriginatingApp] = useState(); @@ -52,7 +53,8 @@ export const VisualizeByValueEditor = ({ onAppLeave }: VisualizeAppProps) => { setValueInput(valueInputValue); setEmbeddableId(embeddableIdValue); if (!valueInputValue) { - history.back(); + // if there is no value input to load, redirect to the visualize listing page. + services.history.replace(VisualizeConstants.LANDING_PAGE_PATH); } }, [services]); 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 39a2db12ffad1..7f971d44af962 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 0000000000000..88389b57dd1db --- /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 0a714e9875c37..b9fa99995ce9d 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 1ac5376b9ed8d..d29a533aa1af1 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 a18ad740681bf..62cc1a7e95754 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 dceb12a02f87f..49b160cc70312 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 56c6485624043..9cd92626f73bf 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 aabd243e48f21..4568ba2b47d80 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 5ca01f239e762..a2cc976f23127 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 4864fcbf3af09..b404b74039be9 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 a30517519820e..de73b2deabbd9 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 4a14d43aec249..19f35ee3083bd 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 c12c633926c1c..7f1db636de32d 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 1263501aa9c13..814a911486698 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 a45403e31095c..94511b0bcf5d4 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 4c72c091a2bee..d18fa31b0694b 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 9931a3fabcd8d..781e364996a56 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 c679ac89f2f61..9420ee2911b98 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/test/scripts/jenkins_unit.sh b/test/scripts/jenkins_unit.sh index a9751003e8425..1f6a3d440734b 100755 --- a/test/scripts/jenkins_unit.sh +++ b/test/scripts/jenkins_unit.sh @@ -2,11 +2,23 @@ source test/scripts/jenkins_test_setup.sh +rename_coverage_file() { + test -f target/kibana-coverage/jest/coverage-final.json \ + && mv target/kibana-coverage/jest/coverage-final.json \ + target/kibana-coverage/jest/$1-coverage-final.json +} + if [[ -z "$CODE_COVERAGE" ]] ; then "$(FORCE_COLOR=0 yarn bin)/grunt" jenkins:unit --dev; else echo " -> Running jest tests with coverage" node scripts/jest --ci --verbose --coverage + rename_coverage_file "oss" + echo "" + echo "" + echo " -> Running jest integration tests with coverage" + node --max-old-space-size=8192 scripts/jest_integration --ci --verbose --coverage || true; + rename_coverage_file "oss-integration" echo "" echo "" echo " -> Running mocha tests with coverage" diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index 2452e2f5b8c58..8bb6effbec89c 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -22,7 +22,8 @@ node scripts/functional_tests --assert-none-excluded \ --include-tag ciGroup7 \ --include-tag ciGroup8 \ --include-tag ciGroup9 \ - --include-tag ciGroup10 + --include-tag ciGroup10 \ + --include-tag ciGroup11 # Do not build kibana for code coverage run if [[ -z "$CODE_COVERAGE" ]] ; then diff --git a/vars/kibanaCoverage.groovy b/vars/kibanaCoverage.groovy index e75ed8fef9875..521672e4bf48c 100644 --- a/vars/kibanaCoverage.groovy +++ b/vars/kibanaCoverage.groovy @@ -145,7 +145,7 @@ def generateReports(title) { source src/dev/ci_setup/setup_env.sh true # bootstrap from x-pack folder cd x-pack - yarn kbn bootstrap --prefer-offline + yarn kbn bootstrap # Return to project root cd .. . src/dev/code_coverage/shell_scripts/extract_archives.sh @@ -172,7 +172,7 @@ def uploadCombinedReports() { def ingestData(jobName, buildNum, buildUrl, previousSha, teamAssignmentsPath, title) { kibanaPipeline.bash(""" source src/dev/ci_setup/setup_env.sh - yarn kbn bootstrap --prefer-offline + yarn kbn bootstrap # Using existing target/kibana-coverage folder . src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh '${jobName}' ${buildNum} '${buildUrl}' '${previousSha}' '${teamAssignmentsPath}' """, title) @@ -249,6 +249,7 @@ def xpackProks() { 'xpack-ciGroup8' : kibanaPipeline.xpackCiGroupProcess(8), 'xpack-ciGroup9' : kibanaPipeline.xpackCiGroupProcess(9), 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), + 'xpack-ciGroup11': kibanaPipeline.xpackCiGroupProcess(11), ] } diff --git a/vars/kibanaTeamAssign.groovy b/vars/kibanaTeamAssign.groovy index caf1ee36e25a8..590d3af4b7bf9 100644 --- a/vars/kibanaTeamAssign.groovy +++ b/vars/kibanaTeamAssign.groovy @@ -1,7 +1,7 @@ def generateTeamAssignments(teamAssignmentsPath, title) { kibanaPipeline.bash(""" source src/dev/ci_setup/setup_env.sh - yarn kbn bootstrap --prefer-offline + yarn kbn bootstrap # Build team assignments dat file node scripts/generate_team_assignments.js --verbose --dest '${teamAssignmentsPath}' diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 5a8161ebd3608..b6bcc0d93f9c0 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -94,7 +94,7 @@ def functionalXpack(Map params = [:]) { kibanaPipeline.buildXpack(10) if (config.ciGroups) { - def ciGroups = 1..10 + def ciGroups = 1..11 tasks(ciGroups.collect { kibanaPipeline.xpackCiGroupProcess(it) }) } diff --git a/x-pack/examples/alerting_example/public/plugin.tsx b/x-pack/examples/alerting_example/public/plugin.tsx index eebb1e2687acc..5e552bd1b1800 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 0000000000000..f4ac2aa220e89 --- /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 ca9e59e050c95..916fe65684a6b 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/common/utils/formatters/formatters.ts b/x-pack/plugins/apm/common/utils/formatters/formatters.ts index 2314e915e3161..50ce9db096610 100644 --- a/x-pack/plugins/apm/common/utils/formatters/formatters.ts +++ b/x-pack/plugins/apm/common/utils/formatters/formatters.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import numeral from '@elastic/numeral'; -import { i18n } from '@kbn/i18n'; import { Maybe } from '../../../typings/common'; import { NOT_AVAILABLE_LABEL } from '../../i18n'; import { isFiniteNumber } from '../is_finite_number'; @@ -17,16 +16,6 @@ export function asInteger(value: number) { return numeral(value).format('0,0'); } -export function tpmUnit(type?: string) { - return type === 'request' - ? i18n.translate('xpack.apm.formatters.requestsPerMinLabel', { - defaultMessage: 'rpm', - }) - : i18n.translate('xpack.apm.formatters.transactionsPerMinLabel', { - defaultMessage: 'tpm', - }); -} - export function asPercent( numerator: Maybe, denominator: number | undefined, diff --git a/x-pack/plugins/apm/jest.config.js b/x-pack/plugins/apm/jest.config.js index 849dd7f5c3e2d..2a5ef9ad0c2a7 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/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index 99316e3520a76..159f111bee04c 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -90,7 +90,12 @@ export function ErrorDistribution({ distribution, title }: Props) { showOverlappingTicks tickFormat={xFormatter} /> - + > = [ }), sortable: true, dataType: 'number', - render: (value: number) => - `${value.toLocaleString()} ${i18n.translate( - 'xpack.apm.tracesTable.tracesPerMinuteUnitLabel', - { - defaultMessage: 'tpm', - } - )}`, + render: (value: number) => asTransactionRate(value), }, { field: 'impact', diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx index e92a6c7db8445..003f2ed05b09e 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx @@ -22,7 +22,7 @@ import { EuiIconTip, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import d3 from 'd3'; import { isEmpty } from 'lodash'; -import React, { useCallback } from 'react'; +import React from 'react'; import { ValuesType } from 'utility-types'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { useTheme } from '../../../../../../observability/public'; @@ -70,46 +70,29 @@ export function getFormattedBuckets( ); } -const getFormatYShort = (transactionType: string | undefined) => ( - t: number -) => { +const formatYShort = (t: number) => { return i18n.translate( 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.unitShortLabel', + { + defaultMessage: '{transCount} trans.', + values: { transCount: t }, + } + ); +}; + +const formatYLong = (t: number) => { + return i18n.translate( + 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel', { defaultMessage: - '{transCount} {transType, select, request {req.} other {trans.}}', + '{transCount, plural, =0 {transactions} one {transaction} other {transactions}}', values: { transCount: t, - transType: transactionType, }, } ); }; -const getFormatYLong = (transactionType: string | undefined) => (t: number) => { - return transactionType === 'request' - ? i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.requestTypeUnitLongLabel', - { - defaultMessage: - '{transCount, plural, =0 {request} one {request} other {requests}}', - values: { - transCount: t, - }, - } - ) - : i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel', - { - defaultMessage: - '{transCount, plural, =0 {transaction} one {transaction} other {transactions}}', - values: { - transCount: t, - }, - } - ); -}; - interface Props { distribution?: TransactionDistributionAPIResponse; urlParams: IUrlParams; @@ -129,16 +112,6 @@ export function TransactionDistribution({ }: Props) { const theme = useTheme(); - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const formatYShort = useCallback(getFormatYShort(transactionType), [ - transactionType, - ]); - - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const formatYLong = useCallback(getFormatYLong(transactionType), [ - transactionType, - ]); - // no data in response if ( (!distribution || distribution.noHits) && @@ -251,7 +224,7 @@ export function TransactionDistribution({ id="y-axis" position={Position.Left} ticks={3} - showGridLines + gridLine={{ visible: true }} tickFormat={(value: number) => formatYShort(value)} /> - + - + 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 22c5a2b101ddc..92eb3753e7989 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_inventory/ServiceList/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/index.tsx index 547a0938bc24d..a4c93f95dc53d 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/index.tsx @@ -14,8 +14,8 @@ import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { ServiceHealthStatus } from '../../../../../common/service_health_status'; import { asPercent, - asDecimal, asMillisecondDuration, + asTransactionRate, } from '../../../../../common/utils/formatters'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { fontSizes, px, truncate, unit } from '../../../../style/variables'; @@ -35,16 +35,6 @@ interface Props { } type ServiceListItem = ValuesType; -function formatNumber(value: number) { - if (value === 0) { - return '0'; - } else if (value <= 0.1) { - return '< 0.1'; - } else { - return asDecimal(value); - } -} - function formatString(value?: string | null) { return value || NOT_AVAILABLE_LABEL; } @@ -154,14 +144,7 @@ export const SERVICE_COLUMNS: Array> = [ ), align: 'left', diff --git a/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx index 5dc1645a1760d..d0f8fc1e61332 100644 --- a/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx @@ -16,7 +16,7 @@ import React, { useMemo } from 'react'; import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts'; import { MetricsChart } from '../../shared/charts/metrics_chart'; import { useUrlParams } from '../../../hooks/useUrlParams'; -import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event_context'; import { Projection } from '../../../../common/projections'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { SearchBar } from '../../shared/search_bar'; @@ -57,7 +57,7 @@ export function ServiceMetrics({ - + {data.charts.map((chart) => ( @@ -73,7 +73,7 @@ export function ServiceMetrics({ ))} - + diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx index 59e919199be76..a74ff574bc0c8 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx @@ -22,7 +22,7 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; import styled from 'styled-components'; import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; -import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event_context'; import { useAgentName } from '../../../hooks/useAgentName'; import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts'; @@ -178,7 +178,7 @@ export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { )} {agentName && ( - + {data.charts.map((chart) => ( @@ -194,12 +194,12 @@ export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { ))} - + )} {agentName && ( - + {data.charts.map((chart) => ( @@ -215,7 +215,7 @@ export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { ))} - + )} 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 f734abe27573c..ddf3107a8ab1e 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 @@ -15,7 +15,8 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; -import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event_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'; @@ -42,7 +43,7 @@ export function ServiceOverview({ useTrackPageview({ app: 'apm', path: 'service_overview', delay: 15000 }); return ( - + @@ -103,22 +104,7 @@ export function ServiceOverview({ - - - - -

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

-
-
-
-
+
@@ -184,6 +170,6 @@ export function ServiceOverview({
-
+ ); } 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 8f9e76a5a79a6..e4ef7428ba8d4 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 95% 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 index ece923631a2f7..9774538b2a7a7 100644 --- 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 @@ -10,8 +10,8 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { - asDecimal, asMillisecondDuration, + asTransactionRate, } from '../../../../../common/utils/formatters'; import { fontFamilyCode, truncate } from '../../../../style/variables'; import { ImpactBar } from '../../../shared/ImpactBar'; @@ -103,13 +103,7 @@ export function TransactionList({ items, isLoading }: Props) { ), sortable: true, dataType: 'number', - render: (value: number) => - `${asDecimal(value)} ${i18n.translate( - 'xpack.apm.transactionsTable.transactionsPerMinuteUnitLabel', - { - defaultMessage: 'tpm', - } - )}`, + render: (value: number) => asTransactionRate(value), }, { field: 'impact', 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 a55b135c6a84e..45a6114c88afd 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 7e18132e59cf3..b13b1f89da352 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/annotations/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/annotations/index.tsx deleted file mode 100644 index 683c66b2a96fe..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/annotations/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - AnnotationDomainTypes, - LineAnnotation, - Position, -} from '@elastic/charts'; -import { EuiIcon } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { asAbsoluteDateTime } from '../../../../../common/utils/formatters'; -import { useTheme } from '../../../../hooks/useTheme'; -import { useAnnotations } from '../../../../hooks/use_annotations'; - -export function Annotations() { - const { annotations } = useAnnotations(); - const theme = useTheme(); - - if (!annotations.length) { - return null; - } - - const color = theme.eui.euiColorSecondary; - - return ( - ({ - dataValue: annotation['@timestamp'], - header: asAbsoluteDateTime(annotation['@timestamp']), - details: `${i18n.translate('xpack.apm.chart.annotation.version', { - defaultMessage: 'Version', - })} ${annotation.text}`, - }))} - style={{ line: { strokeWidth: 1, stroke: color, opacity: 1 } }} - marker={} - markerPosition={Position.Top} - /> - ); -} 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 6f1f4e01c4d1f..73a819af2d624 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 ( - + string; showAnnotations?: boolean; + yDomain?: YDomainRange; } export function TimeseriesChart({ @@ -56,31 +64,29 @@ export function TimeseriesChart({ yLabelFormat, yTickFormat, showAnnotations = true, + yDomain, }: Props) { const history = useHistory(); const chartRef = React.createRef(); - const { event, setEvent } = useChartsSync(); + const { annotations } = useAnnotations(); + const chartTheme = useChartTheme(); + const { pointerEvent, setPointerEvent } = useChartPointerEvent(); const { urlParams } = useUrlParams(); + const theme = useTheme(); + const { start, end } = urlParams; useEffect(() => { - if (event.chartId !== id && chartRef.current) { - chartRef.current.dispatchExternalPointerEvent(event); + if (pointerEvent && pointerEvent?.chartId !== id && chartRef.current) { + chartRef.current.dispatchExternalPointerEvent(pointerEvent); } - }, [event, chartRef, id]); + }, [pointerEvent, chartRef, id]); const min = moment.utc(start).valueOf(); const max = moment.utc(end).valueOf(); const xFormatter = niceTimeFormatter([min, max]); - const chartTheme: SettingsSpec['theme'] = { - lineSeriesStyle: { - point: { visible: false }, - line: { strokeWidth: 2 }, - }, - }; - const isEmpty = timeseries .map((serie) => serie.data) .flat() @@ -89,15 +95,15 @@ export function TimeseriesChart({ y === null || y === undefined ); + const annotationColor = theme.eui.euiColorSecondary; + return ( onBrushEnd({ x, history })} theme={chartTheme} - onPointerUpdate={(currEvent: any) => { - setEvent(currEvent); - }} + onPointerUpdate={setPointerEvent} externalPointerEvents={{ tooltip: { visible: true, placement: Placement.Bottom }, }} @@ -116,17 +122,35 @@ export function TimeseriesChart({ position={Position.Bottom} showOverlappingTicks tickFormat={xFormatter} + gridLine={{ visible: false }} /> - {showAnnotations && } + {showAnnotations && ( + ({ + dataValue: annotation['@timestamp'], + header: asAbsoluteDateTime(annotation['@timestamp']), + details: `${i18n.translate('xpack.apm.chart.annotation.version', { + defaultMessage: 'Version', + })} ${annotation.text}`, + }))} + style={{ + line: { strokeWidth: 1, stroke: annotationColor, opacity: 1 }, + }} + marker={} + markerPosition={Position.Top} + /> + )} {timeseries.map((serie) => { const Series = serie.type === 'area' ? AreaSeries : LineSeries; 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 9b0c041aaf7b5..4d9a1637bea76 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 58% 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 677e4b7593ff1..20056a6831adf 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 @@ -5,71 +5,89 @@ */ import { + AnnotationDomainTypes, AreaSeries, Axis, Chart, CurveType, + LineAnnotation, niceTimeFormatter, Placement, Position, ScaleType, Settings, } from '@elastic/charts'; +import { EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import moment from 'moment'; import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; -import { asPercent } from '../../../../../common/utils/formatters'; +import { useChartTheme } from '../../../../../../observability/public'; +import { + asAbsoluteDateTime, + asPercent, +} from '../../../../../common/utils/formatters'; import { TimeSeries } from '../../../../../typings/timeseries'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import { useTheme } from '../../../../hooks/useTheme'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { useChartsSync as useChartsSync2 } from '../../../../hooks/use_charts_sync'; +import { useAnnotations } from '../../../../hooks/use_annotations'; +import { useChartPointerEvent } from '../../../../hooks/use_chart_pointer_event'; import { unit } from '../../../../style/variables'; -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 { event, setEvent } = useChartsSync2(); + const { annotations } = useAnnotations(); + const chartTheme = useChartTheme(); + const { pointerEvent, setPointerEvent } = useChartPointerEvent(); const { urlParams } = useUrlParams(); + const theme = useTheme(); const { start, end } = urlParams; useEffect(() => { - if (event.chartId !== 'timeSpentBySpan' && chartRef.current) { - chartRef.current.dispatchExternalPointerEvent(event); + if ( + pointerEvent && + pointerEvent.chartId !== 'timeSpentBySpan' && + chartRef.current + ) { + chartRef.current.dispatchExternalPointerEvent(pointerEvent); } - }, [chartRef, event]); + }, [chartRef, pointerEvent]); const min = moment.utc(start).valueOf(); const max = moment.utc(end).valueOf(); const xFormatter = niceTimeFormatter([min, max]); + const annotationColor = theme.eui.euiColorSecondary; + return ( - + onBrushEnd({ x, history })} showLegend showLegendExtra legendPosition={Position.Bottom} + theme={chartTheme} xDomain={{ min, max }} flatLegend - onPointerUpdate={(currEvent: any) => { - setEvent(currEvent); - }} + onPointerUpdate={setPointerEvent} externalPointerEvents={{ tooltip: { visible: true, placement: Placement.Bottom }, }} @@ -79,6 +97,7 @@ export function TransactionBreakdownGraph({ fetchStatus, timeseries }: Props) { position={Position.Bottom} showOverlappingTicks tickFormat={xFormatter} + gridLine={{ visible: false }} /> asPercent(y ?? 0, 1)} /> - + {showAnnotations && ( + ({ + dataValue: annotation['@timestamp'], + header: asAbsoluteDateTime(annotation['@timestamp']), + details: `${i18n.translate('xpack.apm.chart.annotation.version', { + defaultMessage: 'Version', + })} ${annotation.text}`, + }))} + style={{ + line: { strokeWidth: 1, stroke: annotationColor, opacity: 1 }, + }} + marker={} + markerPosition={Position.Top} + /> + )} {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 41212aa7b982c..3f8071ec39f0f 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 @@ -14,22 +14,20 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, TRANSACTION_ROUTE_CHANGE, } from '../../../../../common/transaction_types'; -import { asDecimal, tpmUnit } from '../../../../../common/utils/formatters'; -import { Coordinate } from '../../../../../typings/timeseries'; -import { ChartsSyncContextProvider } from '../../../../context/charts_sync_context'; +import { asTransactionRate } from '../../../../../common/utils/formatters'; +import { AnnotationsContextProvider } from '../../../../context/annotations_context'; +import { ChartPointerEventContextProvider } from '../../../../context/chart_pointer_event_context'; import { LicenseContext } from '../../../../context/LicenseContext'; 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 { TimeseriesChart } from '../timeseries_chart'; +import { TransactionBreakdownChart } from '../transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../transaction_error_rate_chart/'; import { getResponseTimeTickFormatter } from './helper'; import { MLHeader } from './ml_header'; @@ -46,14 +44,6 @@ export function TransactionCharts({ urlParams, fetchStatus, }: TransactionChartProps) { - const getTPMFormatter = (t: number) => { - return `${asDecimal(t)} ${tpmUnit(urlParams.transactionType)}`; - }; - - const getTPMTooltipFormatter = (y: Coordinate['y']) => { - return isValidCoordinateValue(y) ? getTPMFormatter(y) : NOT_AVAILABLE_LABEL; - }; - const { transactionType } = urlParams; const { responseTimeSeries, tpmSeries } = charts; @@ -62,65 +52,69 @@ export function TransactionCharts({ return ( <> - - - - - - - - {responseTimeLabel(transactionType)} - - - - {(license) => ( - - )} - - - { - if (serie) { - toggleSerie(serie); - } - }} - /> - - + + + + + + + + + {responseTimeLabel(transactionType)} + + + + {(license) => ( + + )} + + + { + if (serie) { + toggleSerie(serie); + } + }} + /> + + - - - - {tpmLabel(transactionType)} - - - - - + + + + {tpmLabel(transactionType)} + + + + + - + - - - - - - - - - + + + + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx index b9028ff2e9e8c..00472df95c4b1 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx @@ -91,6 +91,7 @@ export function TransactionErrorRateChart({ ]} yLabelFormat={yLabelFormat} yTickFormat={yTickFormat} + yDomain={{ min: 0, max: 1 }} /> ); diff --git a/x-pack/plugins/apm/public/context/annotations_context.tsx b/x-pack/plugins/apm/public/context/annotations_context.tsx new file mode 100644 index 0000000000000..4e09a3d227b11 --- /dev/null +++ b/x-pack/plugins/apm/public/context/annotations_context.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext } from 'react'; +import { useParams } from 'react-router-dom'; +import { Annotation } from '../../common/annotations'; +import { useFetcher } from '../hooks/useFetcher'; +import { useUrlParams } from '../hooks/useUrlParams'; +import { callApmApi } from '../services/rest/createCallApmApi'; + +export const AnnotationsContext = createContext({ annotations: [] } as { + annotations: Annotation[]; +}); + +const INITIAL_STATE = { annotations: [] }; + +export function AnnotationsContextProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { serviceName } = useParams<{ serviceName?: string }>(); + const { urlParams, uiFilters } = useUrlParams(); + const { start, end } = urlParams; + const { environment } = uiFilters; + + const { data = INITIAL_STATE } = useFetcher(() => { + if (start && end && serviceName) { + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/annotation/search', + params: { + path: { + serviceName, + }, + query: { + start, + end, + environment, + }, + }, + }); + } + }, [start, end, environment, serviceName]); + + return ; +} diff --git a/x-pack/plugins/apm/public/context/charts_sync_context.tsx b/x-pack/plugins/apm/public/context/chart_pointer_event_context.tsx similarity index 52% rename from x-pack/plugins/apm/public/context/charts_sync_context.tsx rename to x-pack/plugins/apm/public/context/chart_pointer_event_context.tsx index d983a857a26ec..ea60206463258 100644 --- a/x-pack/plugins/apm/public/context/charts_sync_context.tsx +++ b/x-pack/plugins/apm/public/context/chart_pointer_event_context.tsx @@ -12,21 +12,23 @@ import React, { useState, } from 'react'; -export const ChartsSyncContext = createContext<{ - event: any; - setEvent: Dispatch>; +import { PointerEvent } from '@elastic/charts'; + +export const ChartPointerEventContext = createContext<{ + pointerEvent: PointerEvent | null; + setPointerEvent: Dispatch>; } | null>(null); -export function ChartsSyncContextProvider({ +export function ChartPointerEventContextProvider({ children, }: { children: ReactNode; }) { - const [event, setEvent] = useState({}); + const [pointerEvent, setPointerEvent] = useState(null); return ( - ); diff --git a/x-pack/plugins/apm/public/hooks/use_annotations.ts b/x-pack/plugins/apm/public/hooks/use_annotations.ts index e8f6785706a91..1cd9a7e65dda2 100644 --- a/x-pack/plugins/apm/public/hooks/use_annotations.ts +++ b/x-pack/plugins/apm/public/hooks/use_annotations.ts @@ -3,36 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { useParams } from 'react-router-dom'; -import { callApmApi } from '../services/rest/createCallApmApi'; -import { useFetcher } from './useFetcher'; -import { useUrlParams } from './useUrlParams'; -const INITIAL_STATE = { annotations: [] }; +import { useContext } from 'react'; +import { AnnotationsContext } from '../context/annotations_context'; export function useAnnotations() { - const { serviceName } = useParams<{ serviceName?: string }>(); - const { urlParams, uiFilters } = useUrlParams(); - const { start, end } = urlParams; - const { environment } = uiFilters; + const context = useContext(AnnotationsContext); - const { data = INITIAL_STATE } = useFetcher(() => { - if (start && end && serviceName) { - return callApmApi({ - endpoint: 'GET /api/apm/services/{serviceName}/annotation/search', - params: { - path: { - serviceName, - }, - query: { - start, - end, - environment, - }, - }, - }); - } - }, [start, end, environment, serviceName]); + if (!context) { + throw new Error('Missing Annotations context provider'); + } - return data; + return context; } diff --git a/x-pack/plugins/apm/public/hooks/use_charts_sync.tsx b/x-pack/plugins/apm/public/hooks/use_chart_pointer_event.tsx similarity index 56% rename from x-pack/plugins/apm/public/hooks/use_charts_sync.tsx rename to x-pack/plugins/apm/public/hooks/use_chart_pointer_event.tsx index cde5c84a6097b..058ec594e2d22 100644 --- a/x-pack/plugins/apm/public/hooks/use_charts_sync.tsx +++ b/x-pack/plugins/apm/public/hooks/use_chart_pointer_event.tsx @@ -5,13 +5,13 @@ */ import { useContext } from 'react'; -import { ChartsSyncContext } from '../context/charts_sync_context'; +import { ChartPointerEventContext } from '../context/chart_pointer_event_context'; -export function useChartsSync() { - const context = useContext(ChartsSyncContext); +export function useChartPointerEvent() { + const context = useContext(ChartPointerEventContext); if (!context) { - throw new Error('Missing ChartsSync context provider'); + throw new Error('Missing ChartPointerEventContext provider'); } return context; 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 1483247686429..686501c1eef4c 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 0000000000000..fd4e6516f9ca3 --- /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/apm/public/selectors/chart_selectors.test.ts b/x-pack/plugins/apm/public/selectors/chart_selectors.test.ts index 4269ec0e6c0f3..a17faebc9aefa 100644 --- a/x-pack/plugins/apm/public/selectors/chart_selectors.test.ts +++ b/x-pack/plugins/apm/public/selectors/chart_selectors.test.ts @@ -144,7 +144,7 @@ describe('chart selectors', () => { { color: errorColor, data: [{ x: 0, y: 0 }], - legendValue: '0.0 tpm', + legendValue: '0 tpm', title: 'HTTP 5xx', type: 'linemark', }, diff --git a/x-pack/plugins/apm/public/selectors/chart_selectors.ts b/x-pack/plugins/apm/public/selectors/chart_selectors.ts index 8330df07c21eb..663fbc9028108 100644 --- a/x-pack/plugins/apm/public/selectors/chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/chart_selectors.ts @@ -20,7 +20,7 @@ import { import { IUrlParams } from '../context/UrlParamsContext/types'; import { getEmptySeries } from '../components/shared/charts/helper/get_empty_series'; import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor'; -import { asDecimal, asDuration, tpmUnit } from '../../common/utils/formatters'; +import { asDuration, asTransactionRate } from '../../common/utils/formatters'; export interface ITpmBucket { title: string; @@ -171,7 +171,7 @@ export function getTpmSeries( return { title: bucket.key, data: bucket.dataPoints, - legendValue: `${asDecimal(bucket.avg)} ${tpmUnit(transactionType || '')}`, + legendValue: asTransactionRate(bucket.avg), type: 'linemark', color: getColor(bucket.key), }; 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 46ea90a9c1b30..78eecf7984865 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 29cdcfccfc756..17f2f95070c59 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 16bcf1b3b9a06..ed0d67bb22149 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 30f4cb85fb58c..edb8cf6ab3abc 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 7bd9a1994ba7e..f77585b5b062c 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 5c0ca74f5225a..4eed89b95132a 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 765ff50728228..380d07972ca4d 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 647c63c2c1042..54702f2654839 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 ae0956ee21283..9bc4bd5e78fd0 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 5e65594972da2..939343b6a28c5 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/data_enhanced/common/index.ts b/x-pack/plugins/data_enhanced/common/index.ts index 61767af030803..dd1a2d39ab5d1 100644 --- a/x-pack/plugins/data_enhanced/common/index.ts +++ b/x-pack/plugins/data_enhanced/common/index.ts @@ -10,9 +10,6 @@ export { EqlRequestParams, EqlSearchStrategyRequest, EqlSearchStrategyResponse, - IAsyncSearchRequest, - IEnhancedEsSearchRequest, IAsyncSearchOptions, - doPartialSearch, - throwOnEsError, + pollSearch, } from './search'; diff --git a/x-pack/plugins/data_enhanced/common/search/es_search/es_search_rxjs_utils.ts b/x-pack/plugins/data_enhanced/common/search/es_search/es_search_rxjs_utils.ts deleted file mode 100644 index 8b25a59ed857a..0000000000000 --- a/x-pack/plugins/data_enhanced/common/search/es_search/es_search_rxjs_utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { of, merge, timer, throwError } from 'rxjs'; -import { map, takeWhile, switchMap, expand, mergeMap, tap } from 'rxjs/operators'; -import { ApiResponse } from '@elastic/elasticsearch'; - -import { - doSearch, - IKibanaSearchResponse, - isErrorResponse, -} from '../../../../../../src/plugins/data/common'; -import { AbortError } from '../../../../../../src/plugins/kibana_utils/common'; -import type { IKibanaSearchRequest } from '../../../../../../src/plugins/data/common'; -import type { IAsyncSearchOptions } from '../../../common/search/types'; - -const DEFAULT_POLLING_INTERVAL = 1000; - -export const doPartialSearch = ( - searchMethod: () => Promise, - partialSearchMethod: (id: IKibanaSearchRequest['id']) => Promise, - isCompleteResponse: (response: SearchResponse) => boolean, - getId: (response: SearchResponse) => IKibanaSearchRequest['id'], - requestId: IKibanaSearchRequest['id'], - { abortSignal, pollInterval = DEFAULT_POLLING_INTERVAL }: IAsyncSearchOptions -) => - doSearch( - requestId ? () => partialSearchMethod(requestId) : searchMethod, - abortSignal - ).pipe( - tap((response) => (requestId = getId(response))), - expand(() => timer(pollInterval).pipe(switchMap(() => partialSearchMethod(requestId)))), - takeWhile((response) => !isCompleteResponse(response), true) - ); - -export const normalizeEqlResponse = () => - map((eqlResponse) => ({ - ...eqlResponse, - body: { - ...eqlResponse.body, - ...eqlResponse, - }, - })); - -export const throwOnEsError = () => - mergeMap((r: IKibanaSearchResponse) => - isErrorResponse(r) ? merge(of(r), throwError(new AbortError())) : of(r) - ); diff --git a/x-pack/plugins/data_enhanced/common/search/index.ts b/x-pack/plugins/data_enhanced/common/search/index.ts index 44f82386e35c3..34bb21cb91af1 100644 --- a/x-pack/plugins/data_enhanced/common/search/index.ts +++ b/x-pack/plugins/data_enhanced/common/search/index.ts @@ -5,4 +5,4 @@ */ export * from './types'; -export * from './es_search'; +export * from './poll_search'; diff --git a/x-pack/plugins/data_enhanced/common/search/poll_search.ts b/x-pack/plugins/data_enhanced/common/search/poll_search.ts new file mode 100644 index 0000000000000..c0e289c691cfd --- /dev/null +++ b/x-pack/plugins/data_enhanced/common/search/poll_search.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { from, NEVER, Observable, timer } from 'rxjs'; +import { expand, finalize, switchMap, takeUntil, takeWhile, tap } from 'rxjs/operators'; +import type { IKibanaSearchResponse } from '../../../../../src/plugins/data/common'; +import { isErrorResponse, isPartialResponse } from '../../../../../src/plugins/data/common'; +import { AbortError, abortSignalToPromise } from '../../../../../src/plugins/kibana_utils/common'; +import type { IAsyncSearchOptions } from './types'; + +export const pollSearch = ( + search: () => Promise, + { pollInterval = 1000, ...options }: IAsyncSearchOptions = {} +): Observable => { + const aborted = options?.abortSignal + ? abortSignalToPromise(options?.abortSignal) + : { promise: NEVER, cleanup: () => {} }; + + return from(search()).pipe( + expand(() => timer(pollInterval).pipe(switchMap(search))), + tap((response) => { + if (isErrorResponse(response)) throw new AbortError(); + }), + takeWhile(isPartialResponse, true), + takeUntil(from(aborted.promise)), + finalize(aborted.cleanup) + ); +}; diff --git a/x-pack/plugins/data_enhanced/common/search/types.ts b/x-pack/plugins/data_enhanced/common/search/types.ts index 4abf8351114f8..f017462d4050b 100644 --- a/x-pack/plugins/data_enhanced/common/search/types.ts +++ b/x-pack/plugins/data_enhanced/common/search/types.ts @@ -9,27 +9,12 @@ import { ApiResponse, TransportRequestOptions } from '@elastic/elasticsearch/lib import { ISearchOptions, - IEsSearchRequest, IKibanaSearchRequest, IKibanaSearchResponse, } from '../../../../../src/plugins/data/common'; export const ENHANCED_ES_SEARCH_STRATEGY = 'ese'; -export interface IAsyncSearchRequest extends IEsSearchRequest { - /** - * The ID received from the response from the initial request - */ - id?: string; -} - -export interface IEnhancedEsSearchRequest extends IEsSearchRequest { - /** - * Used to determine whether to use the _rollups_search or a regular search endpoint. - */ - isRollup?: boolean; -} - export const EQL_SEARCH_STRATEGY = 'eql'; export type EqlRequestParams = EqlSearch>; diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index bc7c8410d3df1..eea0101ec4ed7 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -6,6 +6,7 @@ "xpack", "data_enhanced" ], "requiredPlugins": [ + "bfetch", "data", "features" ], diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 948858a5ed4c1..fa3206446f9fc 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -7,6 +7,7 @@ import React from 'react'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { setAutocompleteService } from './services'; import { setupKqlQuerySuggestionProvider, KUERY_LANGUAGE_NAME } from './autocomplete'; @@ -16,6 +17,7 @@ import { createConnectedBackgroundSessionIndicator } from './search'; import { ConfigSchema } from '../config'; export interface DataEnhancedSetupDependencies { + bfetch: BfetchPublicSetup; data: DataPublicPluginSetup; } export interface DataEnhancedStartDependencies { @@ -33,7 +35,7 @@ export class DataEnhancedPlugin public setup( core: CoreSetup, - { data }: DataEnhancedSetupDependencies + { bfetch, data }: DataEnhancedSetupDependencies ) { data.autocomplete.addQuerySuggestionProvider( KUERY_LANGUAGE_NAME, @@ -41,6 +43,7 @@ export class DataEnhancedPlugin ); this.enhancedSearchInterceptor = new EnhancedSearchInterceptor({ + bfetch, toasts: core.notifications.toasts, http: core.http, uiSettings: core.uiSettings, diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 044489d58eb0e..f4d7422d1c7e2 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -11,6 +11,7 @@ import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; import { AbortError } from '../../../../../src/plugins/kibana_utils/public'; import { SearchTimeoutError } from 'src/plugins/data/public'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; +import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks'; const timeTravel = (msToRun = 0) => { jest.advanceTimersByTime(msToRun); @@ -24,12 +25,13 @@ const complete = jest.fn(); let searchInterceptor: EnhancedSearchInterceptor; let mockCoreSetup: MockedKeys; let mockCoreStart: MockedKeys; +let fetchMock: jest.Mock; jest.useFakeTimers(); function mockFetchImplementation(responses: any[]) { let i = 0; - mockCoreSetup.http.fetch.mockImplementation(() => { + fetchMock.mockImplementation(() => { const { time = 0, value = {}, isError = false } = responses[i++]; return new Promise((resolve, reject) => setTimeout(() => { @@ -46,6 +48,7 @@ describe('EnhancedSearchInterceptor', () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); const dataPluginMockStart = dataPluginMock.createStartContract(); + fetchMock = jest.fn(); mockCoreSetup.uiSettings.get.mockImplementation((name: string) => { switch (name) { @@ -74,7 +77,11 @@ describe('EnhancedSearchInterceptor', () => { ]); }); + const bfetchMock = bfetchPluginMock.createSetupContract(); + bfetchMock.batchedFunction.mockReturnValue(fetchMock); + searchInterceptor = new EnhancedSearchInterceptor({ + bfetch: bfetchMock, toasts: mockCoreSetup.notifications.toasts, startServices: mockPromise as any, http: mockCoreSetup.http, @@ -117,7 +124,7 @@ describe('EnhancedSearchInterceptor', () => { { time: 10, value: { - isPartial: false, + isPartial: true, isRunning: true, id: 1, rawResponse: { @@ -175,8 +182,6 @@ describe('EnhancedSearchInterceptor', () => { await timeTravel(10); - expect(next).toHaveBeenCalled(); - expect(next.mock.calls[0][0]).toStrictEqual(responses[0].value); expect(error).toHaveBeenCalled(); expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); }); @@ -212,7 +217,7 @@ describe('EnhancedSearchInterceptor', () => { { time: 10, value: { - isPartial: false, + isPartial: true, isRunning: true, id: 1, }, @@ -247,7 +252,7 @@ describe('EnhancedSearchInterceptor', () => { expect(error).toHaveBeenCalled(); expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - expect(mockCoreSetup.http.fetch).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); expect(mockCoreSetup.http.delete).toHaveBeenCalled(); }); @@ -271,7 +276,7 @@ describe('EnhancedSearchInterceptor', () => { expect(error).toHaveBeenCalled(); expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); - expect(mockCoreSetup.http.fetch).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalled(); expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); }); @@ -280,7 +285,7 @@ describe('EnhancedSearchInterceptor', () => { { time: 10, value: { - isPartial: false, + isPartial: true, isRunning: true, id: 1, }, @@ -303,7 +308,7 @@ describe('EnhancedSearchInterceptor', () => { expect(next).toHaveBeenCalled(); expect(error).not.toHaveBeenCalled(); - expect(mockCoreSetup.http.fetch).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalled(); expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); // Long enough to reach the timeout but not long enough to reach the next response @@ -311,7 +316,7 @@ describe('EnhancedSearchInterceptor', () => { expect(error).toHaveBeenCalled(); expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); - expect(mockCoreSetup.http.fetch).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); expect(mockCoreSetup.http.delete).toHaveBeenCalled(); }); @@ -320,7 +325,7 @@ describe('EnhancedSearchInterceptor', () => { { time: 10, value: { - isPartial: false, + isPartial: true, isRunning: true, id: 1, }, @@ -345,7 +350,7 @@ describe('EnhancedSearchInterceptor', () => { expect(next).toHaveBeenCalled(); expect(error).not.toHaveBeenCalled(); - expect(mockCoreSetup.http.fetch).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalled(); expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); // Long enough to reach the timeout but not long enough to reach the next response @@ -353,7 +358,7 @@ describe('EnhancedSearchInterceptor', () => { expect(error).toHaveBeenCalled(); expect(error.mock.calls[0][0]).toBe(responses[1].value); - expect(mockCoreSetup.http.fetch).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); expect(mockCoreSetup.http.delete).toHaveBeenCalled(); }); }); @@ -385,9 +390,7 @@ describe('EnhancedSearchInterceptor', () => { await timeTravel(); - const areAllRequestsAborted = mockCoreSetup.http.fetch.mock.calls.every( - ([{ signal }]) => signal?.aborted - ); + const areAllRequestsAborted = fetchMock.mock.calls.every(([_, signal]) => signal?.aborted); expect(areAllRequestsAborted).toBe(true); expect(mockUsageCollector.trackQueriesCancelled).toBeCalledTimes(1); }); diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts index e1bd71caddb4d..9aa35b460b1e8 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -4,24 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { throwError, from, Subscription } from 'rxjs'; -import { tap, takeUntil, finalize, catchError } from 'rxjs/operators'; +import { throwError, Subscription } from 'rxjs'; +import { tap, finalize, catchError } from 'rxjs/operators'; import { TimeoutErrorMode, - IEsSearchResponse, SearchInterceptor, SearchInterceptorDeps, UI_SETTINGS, + IKibanaSearchRequest, } from '../../../../../src/plugins/data/public'; -import { AbortError, abortSignalToPromise } from '../../../../../src/plugins/kibana_utils/public'; - -import { - IAsyncSearchRequest, - ENHANCED_ES_SEARCH_STRATEGY, - IAsyncSearchOptions, - doPartialSearch, - throwOnEsError, -} from '../../common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { ENHANCED_ES_SEARCH_STRATEGY, IAsyncSearchOptions, pollSearch } from '../../common'; export class EnhancedSearchInterceptor extends SearchInterceptor { private uiSettingsSub: Subscription; @@ -60,49 +53,26 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { if (this.deps.usageCollector) this.deps.usageCollector.trackQueriesCancelled(); }; - public search( - request: IAsyncSearchRequest, - { pollInterval = 1000, ...options }: IAsyncSearchOptions = {} - ) { - let { id } = request; - + public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { const { combinedSignal, timeoutSignal, cleanup } = this.setupAbortSignal({ abortSignal: options.abortSignal, timeout: this.searchTimeout, }); - const abortedPromise = abortSignalToPromise(combinedSignal); const strategy = options?.strategy ?? ENHANCED_ES_SEARCH_STRATEGY; + const searchOptions = { ...options, strategy, abortSignal: combinedSignal }; + const search = () => this.runSearch({ id, ...request }, searchOptions); this.pendingCount$.next(this.pendingCount$.getValue() + 1); - return doPartialSearch( - () => this.runSearch(request, { ...options, strategy, abortSignal: combinedSignal }), - (requestId) => - this.runSearch( - { ...request, id: requestId }, - { ...options, strategy, abortSignal: combinedSignal } - ), - (r) => !r.isRunning, - (response) => response.id, - id, - { pollInterval } - ).pipe( - tap((r) => { - id = r.id ?? id; - }), - throwOnEsError(), - takeUntil(from(abortedPromise.promise)), + return pollSearch(search, { ...options, abortSignal: combinedSignal }).pipe( + tap((response) => (id = response.id)), catchError((e: AbortError) => { - if (id) { - this.deps.http.delete(`/internal/search/${strategy}/${id}`); - } - - return throwError(this.handleSearchError(e, request, timeoutSignal, options)); + if (id) this.deps.http.delete(`/internal/search/${strategy}/${id}`); + return throwError(this.handleSearchError(e, timeoutSignal, options)); }), finalize(() => { this.pendingCount$.next(this.pendingCount$.getValue() - 1); cleanup(); - abortedPromise.cleanup(); }) ); } diff --git a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts index cd94d91db8c5e..f2d7725954a26 100644 --- a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts @@ -178,7 +178,7 @@ describe('EQL search strategy', () => { expect(requestOptions).toEqual( expect.objectContaining({ - max_retries: 2, + maxRetries: 2, ignore: [300], }) ); diff --git a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts index 7b3d0db450b04..26325afc378f7 100644 --- a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts @@ -4,21 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { tap } from 'rxjs/operators'; import type { Logger } from 'kibana/server'; -import type { ApiResponse } from '@elastic/elasticsearch'; - -import { search } from '../../../../../src/plugins/data/server'; -import { - doPartialSearch, - normalizeEqlResponse, -} from '../../common/search/es_search/es_search_rxjs_utils'; -import { getAsyncOptions, getDefaultSearchParams } from './get_default_search_params'; - -import type { ISearchStrategy, IEsRawSearchResponse } from '../../../../../src/plugins/data/server'; +import type { ISearchStrategy } from '../../../../../src/plugins/data/server'; import type { EqlSearchStrategyRequest, EqlSearchStrategyResponse, -} from '../../common/search/types'; + IAsyncSearchOptions, +} from '../../common'; +import { getDefaultSearchParams, shimAbortSignal } from '../../../../../src/plugins/data/server'; +import { pollSearch } from '../../common'; +import { getDefaultAsyncGetParams, getIgnoreThrottled } from './request_utils'; +import { toEqlKibanaSearchResponse } from './response_utils'; +import { EqlSearchResponse } from './types'; export const eqlSearchStrategyProvider = ( logger: Logger @@ -26,48 +24,37 @@ export const eqlSearchStrategyProvider = ( return { cancel: async (id, options, { esClient }) => { logger.debug(`_eql/delete ${id}`); - await esClient.asCurrentUser.eql.delete({ - id, - }); + await esClient.asCurrentUser.eql.delete({ id }); }, - search: (request, options, { esClient, uiSettingsClient }) => { - logger.debug(`_eql/search ${JSON.stringify(request.params) || request.id}`); + search: ({ id, ...request }, options: IAsyncSearchOptions, { esClient, uiSettingsClient }) => { + logger.debug(`_eql/search ${JSON.stringify(request.params) || id}`); - const { utils } = search.esSearch; - const asyncOptions = getAsyncOptions(); - const requestOptions = utils.toSnakeCase({ ...request.options }); const client = esClient.asCurrentUser.eql; - return doPartialSearch>( - async () => { - const { ignoreThrottled, ignoreUnavailable } = await getDefaultSearchParams( - uiSettingsClient - ); - - return client.search( - utils.toSnakeCase({ - ignoreThrottled, - ignoreUnavailable, - ...asyncOptions, + const search = async () => { + const { track_total_hits: _, ...defaultParams } = await getDefaultSearchParams( + uiSettingsClient + ); + const params = id + ? getDefaultAsyncGetParams() + : { + ...(await getIgnoreThrottled(uiSettingsClient)), + ...defaultParams, + ...getDefaultAsyncGetParams(), ...request.params, - }) as EqlSearchStrategyRequest['params'], - requestOptions - ); - }, - (id) => - client.get( - { - id: id!, - ...utils.toSnakeCase(asyncOptions), - }, - requestOptions - ), - (response) => !response.body.is_running, - (response) => response.body.id, - request.id, - options - ).pipe(normalizeEqlResponse(), utils.toKibanaSearchResponse()); + }; + const promise = id + ? client.get({ ...params, id }, request.options) + : client.search( + params as EqlSearchStrategyRequest['params'], + request.options + ); + const response = await shimAbortSignal(promise, options.abortSignal); + return toEqlKibanaSearchResponse(response); + }; + + return pollSearch(search, options).pipe(tap((response) => (id = response.id))); }, }; }; diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts index 2070610ceb20e..e1c7d7b5fc22e 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts @@ -4,86 +4,67 @@ * you may not use this file except in compliance with the Elastic License. */ +import type { Observable } from 'rxjs'; +import type { Logger, SharedGlobalConfig } from 'kibana/server'; +import { first, tap } from 'rxjs/operators'; +import { SearchResponse } from 'elasticsearch'; import { from } from 'rxjs'; -import { first, map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; - -import type { SearchResponse } from 'elasticsearch'; -import type { ApiResponse } from '@elastic/elasticsearch'; - -import { - getShardTimeout, - shimHitsTotal, - search, - SearchStrategyDependencies, -} from '../../../../../src/plugins/data/server'; -import { doPartialSearch } from '../../common/search/es_search/es_search_rxjs_utils'; -import { getDefaultSearchParams, getAsyncOptions } from './get_default_search_params'; - -import type { SharedGlobalConfig, Logger } from '../../../../../src/core/server'; - import type { + IEsSearchRequest, + IEsSearchResponse, + ISearchOptions, ISearchStrategy, + SearchStrategyDependencies, SearchUsage, - IEsRawSearchResponse, - ISearchOptions, - IEsSearchResponse, } from '../../../../../src/plugins/data/server'; - -import type { IEnhancedEsSearchRequest } from '../../common'; - -const { utils } = search.esSearch; - -interface IEsRawAsyncSearchResponse extends IEsRawSearchResponse { - response: SearchResponse; -} +import { + getDefaultSearchParams, + getShardTimeout, + getTotalLoaded, + searchUsageObserver, + shimAbortSignal, +} from '../../../../../src/plugins/data/server'; +import type { IAsyncSearchOptions } from '../../common'; +import { pollSearch } from '../../common'; +import { + getDefaultAsyncGetParams, + getDefaultAsyncSubmitParams, + getIgnoreThrottled, +} from './request_utils'; +import { toAsyncKibanaSearchResponse } from './response_utils'; +import { AsyncSearchResponse } from './types'; export const enhancedEsSearchStrategyProvider = ( config$: Observable, logger: Logger, usage?: SearchUsage -): ISearchStrategy => { +): ISearchStrategy => { function asyncSearch( - request: IEnhancedEsSearchRequest, - options: ISearchOptions, + { id, ...request }: IEsSearchRequest, + options: IAsyncSearchOptions, { esClient, uiSettingsClient }: SearchStrategyDependencies ) { - const asyncOptions = getAsyncOptions(); const client = esClient.asCurrentUser.asyncSearch; - return doPartialSearch>( - async () => - client.submit( - utils.toSnakeCase({ - ...(await getDefaultSearchParams(uiSettingsClient)), - batchedReduceSize: 64, - keepOnCompletion: !!options.sessionId, // Always return an ID, even if the request completes quickly - ...asyncOptions, - ...request.params, - }) - ), - (id) => - client.get({ - id: id!, - ...utils.toSnakeCase({ ...asyncOptions }), - }), - (response) => !response.body.is_running, - (response) => response.body.id, - request.id, - options - ).pipe( - utils.toKibanaSearchResponse(), - map((response) => ({ - ...response, - rawResponse: shimHitsTotal(response.rawResponse.response!), - })), - utils.trackSearchStatus(logger, usage), - utils.includeTotalLoaded() + const search = async () => { + const params = id + ? getDefaultAsyncGetParams() + : { ...(await getDefaultAsyncSubmitParams(uiSettingsClient, options)), ...request.params }; + const promise = id + ? client.get({ ...params, id }) + : client.submit(params); + const { body } = await shimAbortSignal(promise, options.abortSignal); + return toAsyncKibanaSearchResponse(body); + }; + + return pollSearch(search, options).pipe( + tap((response) => (id = response.id)), + tap(searchUsageObserver(logger, usage)) ); } async function rollupSearch( - request: IEnhancedEsSearchRequest, + request: IEsSearchRequest, options: ISearchOptions, { esClient, uiSettingsClient }: SearchStrategyDependencies ): Promise { @@ -91,11 +72,12 @@ export const enhancedEsSearchStrategyProvider = ( const { body, index, ...params } = request.params!; const method = 'POST'; const path = encodeURI(`/${index}/_rollup_search`); - const querystring = utils.toSnakeCase({ + const querystring = { ...getShardTimeout(config), + ...(await getIgnoreThrottled(uiSettingsClient)), ...(await getDefaultSearchParams(uiSettingsClient)), ...params, - }); + }; const promise = esClient.asCurrentUser.transport.request({ method, @@ -104,17 +86,16 @@ export const enhancedEsSearchStrategyProvider = ( querystring, }); - const esResponse = await utils.shimAbortSignal(promise, options?.abortSignal); - + const esResponse = await shimAbortSignal(promise, options?.abortSignal); const response = esResponse.body as SearchResponse; return { rawResponse: response, - ...utils.getTotalLoaded(response._shards), + ...getTotalLoaded(response), }; } return { - search: (request, options, deps) => { + search: (request, options: IAsyncSearchOptions, deps) => { logger.debug(`search ${JSON.stringify(request.params) || request.id}`); return request.indexType !== 'rollup' diff --git a/x-pack/plugins/data_enhanced/server/search/get_default_search_params.ts b/x-pack/plugins/data_enhanced/server/search/get_default_search_params.ts deleted file mode 100644 index fdda78798808f..0000000000000 --- a/x-pack/plugins/data_enhanced/server/search/get_default_search_params.ts +++ /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 { IUiSettingsClient } from 'src/core/server'; -import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; - -import { getDefaultSearchParams as getBaseSearchParams } from '../../../../../src/plugins/data/server'; - -/** - @internal - */ -export async function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient) { - const ignoreThrottled = !(await uiSettingsClient.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN)); - - return { - ignoreThrottled, - ...(await getBaseSearchParams(uiSettingsClient)), - }; -} - -/** - @internal - */ -export const getAsyncOptions = (): { - waitForCompletionTimeout: string; - keepAlive: string; -} => ({ - waitForCompletionTimeout: '100ms', // Wait up to 100ms for the response to return - keepAlive: '1m', // Extend the TTL for this search request by one minute, -}); diff --git a/x-pack/plugins/data_enhanced/server/search/request_utils.ts b/x-pack/plugins/data_enhanced/server/search/request_utils.ts new file mode 100644 index 0000000000000..f54ab2199c905 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/search/request_utils.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 { IUiSettingsClient } from 'kibana/server'; +import { + AsyncSearchGet, + AsyncSearchSubmit, + Search, +} from '@elastic/elasticsearch/api/requestParams'; +import { ISearchOptions, UI_SETTINGS } from '../../../../../src/plugins/data/common'; +import { getDefaultSearchParams } from '../../../../../src/plugins/data/server'; + +/** + * @internal + */ +export async function getIgnoreThrottled( + uiSettingsClient: IUiSettingsClient +): Promise> { + const includeFrozen = await uiSettingsClient.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); + return { ignore_throttled: !includeFrozen }; +} + +/** + @internal + */ +export async function getDefaultAsyncSubmitParams( + uiSettingsClient: IUiSettingsClient, + options: ISearchOptions +): Promise< + Pick< + AsyncSearchSubmit, + | 'batched_reduce_size' + | 'keep_alive' + | 'wait_for_completion_timeout' + | 'ignore_throttled' + | 'max_concurrent_shard_requests' + | 'ignore_unavailable' + | 'track_total_hits' + | 'keep_on_completion' + > +> { + return { + batched_reduce_size: 64, + keep_on_completion: !!options.sessionId, // Always return an ID, even if the request completes quickly + ...getDefaultAsyncGetParams(), + ...(await getIgnoreThrottled(uiSettingsClient)), + ...(await getDefaultSearchParams(uiSettingsClient)), + }; +} + +/** + @internal + */ +export function getDefaultAsyncGetParams(): Pick< + AsyncSearchGet, + 'keep_alive' | 'wait_for_completion_timeout' +> { + return { + keep_alive: '1m', // Extend the TTL for this search request by one minute + wait_for_completion_timeout: '100ms', // Wait up to 100ms for the response to return + }; +} diff --git a/x-pack/plugins/data_enhanced/server/search/response_utils.ts b/x-pack/plugins/data_enhanced/server/search/response_utils.ts new file mode 100644 index 0000000000000..716e7d72d80e7 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/search/response_utils.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ApiResponse } from '@elastic/elasticsearch'; +import { getTotalLoaded } from '../../../../../src/plugins/data/server'; +import { AsyncSearchResponse, EqlSearchResponse } from './types'; +import { EqlSearchStrategyResponse } from '../../common/search'; + +/** + * Get the Kibana representation of an async search response (see `IKibanaSearchResponse`). + */ +export function toAsyncKibanaSearchResponse(response: AsyncSearchResponse) { + return { + id: response.id, + rawResponse: response.response, + isPartial: response.is_partial, + isRunning: response.is_running, + ...getTotalLoaded(response.response), + }; +} + +/** + * Get the Kibana representation of an EQL search response (see `IKibanaSearchResponse`). + * (EQL does not provide _shard info, so total/loaded cannot be calculated.) + */ +export function toEqlKibanaSearchResponse( + response: ApiResponse +): EqlSearchStrategyResponse { + return { + id: response.body.id, + rawResponse: response, + isPartial: response.body.is_partial, + isRunning: response.body.is_running, + }; +} diff --git a/x-pack/plugins/data_enhanced/server/search/types.ts b/x-pack/plugins/data_enhanced/server/search/types.ts new file mode 100644 index 0000000000000..f01ac51a1516e --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/search/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; + +export interface AsyncSearchResponse { + id?: string; + response: SearchResponse; + is_partial: boolean; + is_running: boolean; +} + +export interface EqlSearchResponse extends SearchResponse { + id?: string; + is_partial: boolean; + is_running: boolean; +} 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 40e7691e621fd..30de6c0802713 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 d60ab5c7d37f0..36a3895c61615 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 ab91666d4acb6..95843a243a3c6 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 c8872fe43a184..ea7eeea750cc4 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 7d69cd2b4d4da..e9805ab8f2711 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 5936b8f2d4283..8aa8731d6da48 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 b3faa73dfaed6..ec340f70fa7b1 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 4197813feba0f..7f1924d2870d2 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 3436df851c8d8..1271015e40e52 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 0000000000000..7e774616ff598 --- /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 4d4ff5f52ef20..8fa3ccdcb863e 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 a763518d30b99..3115e233a6058 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 28f500a2c8a39..7d3db4d36692e 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 0000000000000..3c93e3fd49dcc --- /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 0000000000000..7a7dfa62dbe39 --- /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 0000000000000..dc84f20fade03 --- /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 c367424d375f9..e958bf477c6f6 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 0000000000000..7c661354289aa --- /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 0000000000000..91f6a770edd7d --- /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 802a10e3b3db7..748f4b06f7cac 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 c96e95a41f2e4..fcae2fb87683a 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 0000000000000..12b70d908834d --- /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 6fa6698e6b6ba..de6c75d60189e 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 5f93694da09b8..2ac3f518e4e11 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 0000000000000..b04d5b8bc218f --- /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 c17b89c93a28b..4007f7a69f77a 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 dec9e25fe2440..1af5420a164be 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 1846115d73900..327ee7b30582b 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 5f1e2dd18d3b6..20b15bcfc45ca 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 776cae24dfdfb..562a2ffb32888 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 d03c0abb441b9..3fddcf3b77fe4 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 be95c6ffe6f38..14c288de5a0c8 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,72 +44,72 @@ 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`; export const ADD_BOX_PATH = `${SOURCES_PATH}/add/box`; -export const ADD_CONFLUENCE_PATH = `${SOURCES_PATH}/add/confluence-cloud`; -export const ADD_CONFLUENCE_SERVER_PATH = `${SOURCES_PATH}/add/confluence-server`; +export const ADD_CONFLUENCE_PATH = `${SOURCES_PATH}/add/confluence_cloud`; +export const ADD_CONFLUENCE_SERVER_PATH = `${SOURCES_PATH}/add/confluence_server`; export const ADD_DROPBOX_PATH = `${SOURCES_PATH}/add/dropbox`; -export const ADD_GITHUB_ENTERPRISE_PATH = `${SOURCES_PATH}/add/github-enterprise-server`; +export const ADD_GITHUB_ENTERPRISE_PATH = `${SOURCES_PATH}/add/github_enterprise_server`; export const ADD_GITHUB_PATH = `${SOURCES_PATH}/add/github`; export const ADD_GMAIL_PATH = `${SOURCES_PATH}/add/gmail`; -export const ADD_GOOGLE_DRIVE_PATH = `${SOURCES_PATH}/add/google-drive`; -export const ADD_JIRA_PATH = `${SOURCES_PATH}/add/jira-cloud`; -export const ADD_JIRA_SERVER_PATH = `${SOURCES_PATH}/add/jira-server`; +export const ADD_GOOGLE_DRIVE_PATH = `${SOURCES_PATH}/add/google_drive`; +export const ADD_JIRA_PATH = `${SOURCES_PATH}/add/jira_cloud`; +export const ADD_JIRA_SERVER_PATH = `${SOURCES_PATH}/add/jira_server`; export const ADD_ONEDRIVE_PATH = `${SOURCES_PATH}/add/onedrive`; export const ADD_SALESFORCE_PATH = `${SOURCES_PATH}/add/salesforce`; -export const ADD_SALESFORCE_SANDBOX_PATH = `${SOURCES_PATH}/add/salesforce-sandbox`; +export const ADD_SALESFORCE_SANDBOX_PATH = `${SOURCES_PATH}/add/salesforce_sandbox`; export const ADD_SERVICENOW_PATH = `${SOURCES_PATH}/add/servicenow`; export const ADD_SHAREPOINT_PATH = `${SOURCES_PATH}/add/sharepoint`; 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`; export const SOURCE_SCHEMAS_PATH = `${SOURCES_PATH}/:sourceId/schemas`; -export const SOURCE_DISPLAY_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/display-settings`; +export const SOURCE_DISPLAY_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/display_settings`; export const SOURCE_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/settings`; -export const REINDEX_JOB_PATH = `${SOURCES_PATH}/:sourceId/schema-errors/:activeReindexJobId`; +export const REINDEX_JOB_PATH = `${SOURCES_PATH}/:sourceId/schema_errors/:activeReindexJobId`; 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 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`; export const EDIT_BOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/box/edit`; -export const EDIT_CONFLUENCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence-cloud/edit`; -export const EDIT_CONFLUENCE_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence-server/edit`; +export const EDIT_CONFLUENCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_cloud/edit`; +export const EDIT_CONFLUENCE_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_server/edit`; export const EDIT_DROPBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/dropbox/edit`; -export const EDIT_GITHUB_ENTERPRISE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github-enterprise-server/edit`; +export const EDIT_GITHUB_ENTERPRISE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github_enterprise_server/edit`; export const EDIT_GITHUB_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github/edit`; export const EDIT_GMAIL_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/gmail/edit`; -export const EDIT_GOOGLE_DRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/google-drive/edit`; -export const EDIT_JIRA_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira-cloud/edit`; -export const EDIT_JIRA_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira-server/edit`; +export const EDIT_GOOGLE_DRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/google_drive/edit`; +export const EDIT_JIRA_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_cloud/edit`; +export const EDIT_JIRA_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_server/edit`; export const EDIT_ONEDRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/onedrive/edit`; export const EDIT_SALESFORCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce/edit`; -export const EDIT_SALESFORCE_SANDBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce-sandbox/edit`; +export const EDIT_SALESFORCE_SANDBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce_sandbox/edit`; export const EDIT_SERVICENOW_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/servicenow/edit`; export const EDIT_SHAREPOINT_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/sharepoint/edit`; export const EDIT_SLACK_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/slack/edit`; @@ -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/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index 73e7f7ed701d8..9bda686ebbf00 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -181,3 +181,26 @@ export interface CustomSource { name: string; id: string; } + +export interface Result { + [key: string]: string; +} + +export interface OptionValue { + value: string; + text: string; +} + +export interface DetailField { + fieldName: string; + label: string; +} + +export interface SearchResultConfig { + titleField: string | null; + subtitleField: string | null; + descriptionField: string | null; + urlField: string | null; + color: string; + detailFields: DetailField[]; +} 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 2bf5134e59e26..3e616a70031ac 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 a95d5ca75b0b6..fbd053f9b8374 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 ad183181b4eca..f9123ab4e1cca 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/custom_source_icon.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/custom_source_icon.tsx new file mode 100644 index 0000000000000..16129324b56d1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/custom_source_icon.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +const BLACK_RGB = '#000'; + +interface CustomSourceIconProps { + color?: string; +} + +export const CustomSourceIcon: React.FC = ({ color = BLACK_RGB }) => ( + + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.scss new file mode 100644 index 0000000000000..27935104f4ef6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.scss @@ -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. + */ + +// -------------------------------------------------- +// Custom Source display settings +// -------------------------------------------------- + +@mixin source_name { + font-size: .6875em; + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.06em; +} + +@mixin example_result_box_shadow { + box-shadow: + 0 1px 3px rgba(black, 0.1), + 0 0 20px $euiColorLightestShade; +} + +// Wrapper +.custom-source-display-settings { + font-size: 16px; +} + +// Example result content +.example-result-content { + & > * { + line-height: 1.5em; + } + + &__title { + font-size: 1em; + font-weight: 600; + color: $euiColorPrimary; + + .example-result-detail-card & { + font-size: 20px; + } + } + + &__subtitle, + &__description { + font-size: .875; + } + + &__subtitle { + color: $euiColorDarkestShade; + } + + &__description { + padding: .1rem 0 .125rem .35rem; + border-left: 3px solid $euiColorLightShade; + color: $euiColorDarkShade; + line-height: 1.8; + word-break: break-word; + + @supports (display: -webkit-box) { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__url { + .example-result-detail-card & { + color: $euiColorDarkShade; + } + } +} + +.example-result-content-placeholder { + color: $euiColorMediumShade; +} + +// Example standout result +.example-standout-result { + border-radius: 4px; + overflow: hidden; + @include example_result_box_shadow; + + &__header, + &__content { + padding-left: 1em; + padding-right: 1em; + } + + &__content { + padding-top: 1em; + padding-bottom: 1em; + } + + &__source-name { + line-height: 34px; + @include source_name; + } +} + +// Example result group +.example-result-group { + &__header { + padding: 0 .5em; + border-radius: 4px; + display: inline-flex; + align-items: center; + + .euiIcon { + margin-right: .25rem; + } + } + + &__source-name { + line-height: 1.75em; + @include source_name; + } + + &__content { + display: flex; + align-items: stretch; + padding: .75em 0; + } + + &__border { + width: 4px; + border-radius: 2px; + flex-shrink: 0; + margin-left: .875rem; + } + + &__results { + flex: 1; + max-width: 100%; + } +} + +.example-grouped-result { + padding: 1em; +} + +.example-result-field-hover { + background: lighten($euiColorVis1_behindText, 30%); + position: relative; + + &:before, + &:after { + content: ''; + position: absolute; + height: 100%; + width: 4px; + background: lighten($euiColorVis1_behindText, 30%); + } + + &:before { + right: 100%; + border-radius: 2px 0 0 2px; + } + + &:after { + left: 100%; + border-radius: 0 2px 2px 0; + } + + .example-result-content-placeholder { + color: $euiColorFullShade; + } +} + +.example-result-detail-card { + @include example_result_box_shadow; + + &__header { + position: relative; + padding: 1.25em 1em 0; + } + + &__border { + height: 4px; + position: absolute; + top: 0; + right: 0; + left: 0; + } + + &__source-name { + margin-bottom: 1em; + font-weight: 500; + } + + &__field { + padding: 1em; + + & + & { + border-top: 1px solid $euiColorLightShade; + } + } +} + +.visible-fields-container { + background: $euiColorLightestShade; + border-color: transparent; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx new file mode 100644 index 0000000000000..e34728beef5e5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FormEvent, useEffect } from 'react'; + +import { History } from 'history'; +import { useActions, useValues } from 'kea'; +import { useHistory } from 'react-router-dom'; + +import './display_settings.scss'; + +import { + EuiButton, + EuiEmptyPrompt, + EuiTabbedContent, + EuiPanel, + EuiTabbedContentTab, +} from '@elastic/eui'; + +import { + DISPLAY_SETTINGS_RESULT_DETAIL_PATH, + DISPLAY_SETTINGS_SEARCH_RESULT_PATH, + getContentSourcePath, +} from '../../../../routes'; + +import { AppLogic } from '../../../../app_logic'; + +import { Loading } from '../../../../../shared/loading'; +import { ViewContentHeader } from '../../../../components/shared/view_content_header'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { FieldEditorModal } from './field_editor_modal'; +import { ResultDetail } from './result_detail'; +import { SearchResults } from './search_results'; + +const UNSAVED_MESSAGE = + 'Your display settings have not been saved. Are you sure you want to leave?'; + +interface DisplaySettingsProps { + tabId: number; +} + +export const DisplaySettings: React.FC = ({ tabId }) => { + const history = useHistory() as History; + const { initializeDisplaySettings, setServerData, resetDisplaySettingsState } = useActions( + DisplaySettingsLogic + ); + + const { + dataLoading, + sourceId, + addFieldModalVisible, + unsavedChanges, + exampleDocuments, + } = useValues(DisplaySettingsLogic); + + const { isOrganization } = useValues(AppLogic); + + const hasDocuments = exampleDocuments.length > 0; + + useEffect(() => { + initializeDisplaySettings(); + return resetDisplaySettingsState; + }, []); + + useEffect(() => { + window.onbeforeunload = hasDocuments && unsavedChanges ? () => UNSAVED_MESSAGE : null; + return () => { + window.onbeforeunload = null; + }; + }, [unsavedChanges]); + + if (dataLoading) return ; + + const tabs = [ + { + id: 'search_results', + name: 'Search Results', + content: , + }, + { + id: 'result_detail', + name: 'Result Detail', + content: , + }, + ] as EuiTabbedContentTab[]; + + const onSelectedTabChanged = (tab: EuiTabbedContentTab) => { + const path = + tab.id === tabs[1].id + ? getContentSourcePath(DISPLAY_SETTINGS_RESULT_DETAIL_PATH, sourceId, isOrganization) + : getContentSourcePath(DISPLAY_SETTINGS_SEARCH_RESULT_PATH, sourceId, isOrganization); + + history.push(path); + }; + + const handleFormSubmit = (e: FormEvent) => { + e.preventDefault(); + setServerData(); + }; + + return ( + <> +
+ + Save + + ) : null + } + /> + {hasDocuments ? ( + + ) : ( + + You have no content yet} + body={ +

You need some content to display in order to configure the display settings.

+ } + /> +
+ )} + + {addFieldModalVisible && } + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts new file mode 100644 index 0000000000000..c52665524f566 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts @@ -0,0 +1,350 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { cloneDeep, isEqual, differenceBy } from 'lodash'; +import { DropResult } from 'react-beautiful-dnd'; + +import { kea, MakeLogicType } from 'kea'; + +import { HttpLogic } from '../../../../../shared/http'; + +import { + setSuccessMessage, + FlashMessagesLogic, + flashAPIErrors, +} from '../../../../../shared/flash_messages'; + +import { AppLogic } from '../../../../app_logic'; +import { SourceLogic } from '../../source_logic'; + +const SUCCESS_MESSAGE = 'Display Settings have been successfuly updated.'; + +import { DetailField, SearchResultConfig, OptionValue, Result } from '../../../../types'; + +export interface DisplaySettingsResponseProps { + sourceName: string; + searchResultConfig: SearchResultConfig; + schemaFields: object; + exampleDocuments: Result[]; +} + +export interface DisplaySettingsInitialData extends DisplaySettingsResponseProps { + sourceId: string; + serverRoute: string; +} + +interface DisplaySettingsActions { + initializeDisplaySettings(): void; + setServerData(): void; + onInitializeDisplaySettings( + displaySettingsProps: DisplaySettingsInitialData + ): DisplaySettingsInitialData; + setServerResponseData( + displaySettingsProps: DisplaySettingsResponseProps + ): DisplaySettingsResponseProps; + setTitleField(titleField: string | null): string | null; + setUrlField(urlField: string): string; + setSubtitleField(subtitleField: string | null): string | null; + setDescriptionField(descriptionField: string | null): string | null; + setColorField(hex: string): string; + setDetailFields(result: DropResult): { result: DropResult }; + openEditDetailField(editFieldIndex: number | null): number | null; + removeDetailField(index: number): number; + addDetailField(newField: DetailField): DetailField; + updateDetailField( + updatedField: DetailField, + index: number | null + ): { updatedField: DetailField; index: number }; + toggleFieldEditorModal(): void; + toggleTitleFieldHover(): void; + toggleSubtitleFieldHover(): void; + toggleDescriptionFieldHover(): void; + toggleUrlFieldHover(): void; + resetDisplaySettingsState(): void; +} + +interface DisplaySettingsValues { + sourceName: string; + sourceId: string; + schemaFields: object; + exampleDocuments: Result[]; + serverSearchResultConfig: SearchResultConfig; + searchResultConfig: SearchResultConfig; + serverRoute: string; + editFieldIndex: number | null; + dataLoading: boolean; + addFieldModalVisible: boolean; + titleFieldHover: boolean; + urlFieldHover: boolean; + subtitleFieldHover: boolean; + descriptionFieldHover: boolean; + fieldOptions: OptionValue[]; + optionalFieldOptions: OptionValue[]; + availableFieldOptions: OptionValue[]; + unsavedChanges: boolean; +} + +const defaultSearchResultConfig = { + titleField: '', + subtitleField: '', + descriptionField: '', + urlField: '', + color: '#000000', + detailFields: [], +}; + +export const DisplaySettingsLogic = kea< + MakeLogicType +>({ + actions: { + onInitializeDisplaySettings: (displaySettingsProps: DisplaySettingsInitialData) => + displaySettingsProps, + setServerResponseData: (displaySettingsProps: DisplaySettingsResponseProps) => + displaySettingsProps, + setTitleField: (titleField: string) => titleField, + setUrlField: (urlField: string) => urlField, + setSubtitleField: (subtitleField: string | null) => subtitleField, + setDescriptionField: (descriptionField: string) => descriptionField, + setColorField: (hex: string) => hex, + setDetailFields: (result: DropResult) => ({ result }), + openEditDetailField: (editFieldIndex: number | null) => editFieldIndex, + removeDetailField: (index: number) => index, + addDetailField: (newField: DetailField) => newField, + updateDetailField: (updatedField: DetailField, index: number) => ({ updatedField, index }), + toggleFieldEditorModal: () => true, + toggleTitleFieldHover: () => true, + toggleSubtitleFieldHover: () => true, + toggleDescriptionFieldHover: () => true, + toggleUrlFieldHover: () => true, + resetDisplaySettingsState: () => true, + initializeDisplaySettings: () => true, + setServerData: () => true, + }, + reducers: { + sourceName: [ + '', + { + onInitializeDisplaySettings: (_, { sourceName }) => sourceName, + }, + ], + sourceId: [ + '', + { + onInitializeDisplaySettings: (_, { sourceId }) => sourceId, + }, + ], + schemaFields: [ + {}, + { + onInitializeDisplaySettings: (_, { schemaFields }) => schemaFields, + }, + ], + exampleDocuments: [ + [], + { + onInitializeDisplaySettings: (_, { exampleDocuments }) => exampleDocuments, + }, + ], + serverSearchResultConfig: [ + defaultSearchResultConfig, + { + onInitializeDisplaySettings: (_, { searchResultConfig }) => + setDefaultColor(searchResultConfig), + setServerResponseData: (_, { searchResultConfig }) => searchResultConfig, + }, + ], + searchResultConfig: [ + defaultSearchResultConfig, + { + onInitializeDisplaySettings: (_, { searchResultConfig }) => + setDefaultColor(searchResultConfig), + setServerResponseData: (_, { searchResultConfig }) => searchResultConfig, + setTitleField: (searchResultConfig, titleField) => ({ ...searchResultConfig, titleField }), + setSubtitleField: (searchResultConfig, subtitleField) => ({ + ...searchResultConfig, + subtitleField, + }), + setUrlField: (searchResultConfig, urlField) => ({ ...searchResultConfig, urlField }), + setDescriptionField: (searchResultConfig, descriptionField) => ({ + ...searchResultConfig, + descriptionField, + }), + setColorField: (searchResultConfig, color) => ({ ...searchResultConfig, color }), + setDetailFields: (searchResultConfig, { result: { destination, source } }) => { + const detailFields = cloneDeep(searchResultConfig.detailFields); + const element = detailFields[source.index]; + detailFields.splice(source.index, 1); + detailFields.splice(destination!.index, 0, element); + return { + ...searchResultConfig, + detailFields, + }; + }, + addDetailField: (searchResultConfig, newfield) => { + const detailFields = cloneDeep(searchResultConfig.detailFields); + detailFields.push(newfield); + return { + ...searchResultConfig, + detailFields, + }; + }, + removeDetailField: (searchResultConfig, index) => { + const detailFields = cloneDeep(searchResultConfig.detailFields); + detailFields.splice(index, 1); + return { + ...searchResultConfig, + detailFields, + }; + }, + updateDetailField: (searchResultConfig, { updatedField, index }) => { + const detailFields = cloneDeep(searchResultConfig.detailFields); + detailFields[index] = updatedField; + return { + ...searchResultConfig, + detailFields, + }; + }, + }, + ], + serverRoute: [ + '', + { + onInitializeDisplaySettings: (_, { serverRoute }) => serverRoute, + }, + ], + editFieldIndex: [ + null, + { + openEditDetailField: (_, openEditDetailField) => openEditDetailField, + toggleFieldEditorModal: () => null, + }, + ], + dataLoading: [ + true, + { + onInitializeDisplaySettings: () => false, + }, + ], + addFieldModalVisible: [ + false, + { + toggleFieldEditorModal: (addFieldModalVisible) => !addFieldModalVisible, + openEditDetailField: () => true, + updateDetailField: () => false, + addDetailField: () => false, + }, + ], + titleFieldHover: [ + false, + { + toggleTitleFieldHover: (titleFieldHover) => !titleFieldHover, + }, + ], + urlFieldHover: [ + false, + { + toggleUrlFieldHover: (urlFieldHover) => !urlFieldHover, + }, + ], + subtitleFieldHover: [ + false, + { + toggleSubtitleFieldHover: (subtitleFieldHover) => !subtitleFieldHover, + }, + ], + descriptionFieldHover: [ + false, + { + toggleDescriptionFieldHover: (addFieldModalVisible) => !addFieldModalVisible, + }, + ], + }, + selectors: ({ selectors }) => ({ + fieldOptions: [ + () => [selectors.schemaFields], + (schemaFields) => Object.keys(schemaFields).map(euiSelectObjectFromValue), + ], + optionalFieldOptions: [ + () => [selectors.fieldOptions], + (fieldOptions) => { + const optionalFieldOptions = cloneDeep(fieldOptions); + optionalFieldOptions.unshift({ value: '', text: '' }); + return optionalFieldOptions; + }, + ], + // We don't want to let the user add a duplicate detailField. + availableFieldOptions: [ + () => [selectors.fieldOptions, selectors.searchResultConfig], + (fieldOptions, { detailFields }) => { + const usedFields = detailFields.map((usedField: DetailField) => + euiSelectObjectFromValue(usedField.fieldName) + ); + return differenceBy(fieldOptions, usedFields, 'value'); + }, + ], + unsavedChanges: [ + () => [selectors.searchResultConfig, selectors.serverSearchResultConfig], + (uiConfig, serverConfig) => !isEqual(uiConfig, serverConfig), + ], + }), + listeners: ({ actions, values }) => ({ + initializeDisplaySettings: async () => { + const { isOrganization } = AppLogic.values; + const { + contentSource: { id: sourceId }, + } = SourceLogic.values; + + const route = isOrganization + ? `/api/workplace_search/org/sources/${sourceId}/display_settings/config` + : `/api/workplace_search/account/sources/${sourceId}/display_settings/config`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.onInitializeDisplaySettings({ + isOrganization, + sourceId, + serverRoute: route, + ...response, + }); + } catch (e) { + flashAPIErrors(e); + } + }, + setServerData: async () => { + const { searchResultConfig, serverRoute } = values; + + try { + const response = await HttpLogic.values.http.post(serverRoute, { + body: JSON.stringify({ ...searchResultConfig }), + }); + actions.setServerResponseData(response); + } catch (e) { + flashAPIErrors(e); + } + }, + setServerResponseData: () => { + setSuccessMessage(SUCCESS_MESSAGE); + }, + toggleFieldEditorModal: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + resetDisplaySettingsState: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + }), +}); + +const euiSelectObjectFromValue = (value: string) => ({ text: value, value }); + +// By default, the color is `null` on the server. The color is a required field and the +// EuiColorPicker components doesn't allow the field to be required so the form can be +// submitted with no color and this results in a server error. The default should be black +// and this allows the `searchResultConfig` and the `serverSearchResultConfig` reducers to +// stay synced on initialization. +const setDefaultColor = (searchResultConfig: SearchResultConfig) => ({ + ...searchResultConfig, + color: searchResultConfig.color || '#000000', +}); 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 0000000000000..01ac93735b8a8 --- /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,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useValues } from 'kea'; +import { Route, Switch } from 'react-router-dom'; + +import { AppLogic } from '../../../../app_logic'; + +import { + DISPLAY_SETTINGS_RESULT_DETAIL_PATH, + DISPLAY_SETTINGS_SEARCH_RESULT_PATH, + getSourcesPath, +} from '../../../../routes'; + +import { DisplaySettings } from './display_settings'; + +export const DisplaySettingsRouter: React.FC = () => { + const { isOrganization } = useValues(AppLogic); + return ( + + } + /> + } + /> + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx new file mode 100644 index 0000000000000..468f7d2f7ad05 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import classNames from 'classnames'; +import { useValues } from 'kea'; + +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { CustomSourceIcon } from './custom_source_icon'; +import { TitleField } from './title_field'; + +export const ExampleResultDetailCard: React.FC = () => { + const { + sourceName, + searchResultConfig: { titleField, urlField, color, detailFields }, + titleFieldHover, + urlFieldHover, + exampleDocuments, + } = useValues(DisplaySettingsLogic); + + const result = exampleDocuments[0]; + + return ( +
+
+
+
+ + + + + {sourceName} + +
+
+ +
+ {urlField ? ( +
{result[urlField]}
+ ) : ( + URL + )} +
+
+
+
+ {detailFields.length > 0 ? ( + detailFields.map(({ fieldName, label }, index) => ( +
+ +

{label}

+
+ +
{result[fieldName]}
+
+
+ )) + ) : ( + + )} +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx new file mode 100644 index 0000000000000..14239b1654308 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { isColorDark, hexToRgb } from '@elastic/eui'; +import classNames from 'classnames'; +import { useValues } from 'kea'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { CustomSourceIcon } from './custom_source_icon'; +import { SubtitleField } from './subtitle_field'; +import { TitleField } from './title_field'; + +export const ExampleSearchResultGroup: React.FC = () => { + const { + sourceName, + searchResultConfig: { titleField, subtitleField, descriptionField, color }, + titleFieldHover, + subtitleFieldHover, + descriptionFieldHover, + exampleDocuments, + } = useValues(DisplaySettingsLogic); + + return ( +
+
+ + + {sourceName} + +
+
+
+
+ {exampleDocuments.map((result, id) => ( +
+
+ + +
+ {descriptionField ? ( +
{result[descriptionField]}
+ ) : ( + Description + )} +
+
+
+ ))} +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx new file mode 100644 index 0000000000000..4ef3b1fe14b93 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import classNames from 'classnames'; +import { useValues } from 'kea'; + +import { isColorDark, hexToRgb } from '@elastic/eui'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { CustomSourceIcon } from './custom_source_icon'; +import { SubtitleField } from './subtitle_field'; +import { TitleField } from './title_field'; + +export const ExampleStandoutResult: React.FC = () => { + const { + sourceName, + searchResultConfig: { titleField, subtitleField, descriptionField, color }, + titleFieldHover, + subtitleFieldHover, + descriptionFieldHover, + exampleDocuments, + } = useValues(DisplaySettingsLogic); + + const result = exampleDocuments[0]; + + return ( +
+
+ + + {sourceName} + +
+
+
+ + +
+ {descriptionField ? ( + {result[descriptionField]} + ) : ( + Description + )} +
+
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx new file mode 100644 index 0000000000000..587916a741d66 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx @@ -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 React, { FormEvent, useState } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSelect, +} from '@elastic/eui'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +const emptyField = { fieldName: '', label: '' }; + +export const FieldEditorModal: React.FC = () => { + const { toggleFieldEditorModal, addDetailField, updateDetailField } = useActions( + DisplaySettingsLogic + ); + + const { + searchResultConfig: { detailFields }, + availableFieldOptions, + fieldOptions, + editFieldIndex, + } = useValues(DisplaySettingsLogic); + + const isEditing = editFieldIndex || editFieldIndex === 0; + const field = isEditing ? detailFields[editFieldIndex || 0] : emptyField; + const [fieldName, setName] = useState(field.fieldName || ''); + const [label, setLabel] = useState(field.label || ''); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (isEditing) { + updateDetailField({ fieldName, label }, editFieldIndex); + } else { + addDetailField({ fieldName, label }); + } + }; + + const ACTION_LABEL = isEditing ? 'Update' : 'Add'; + + return ( + +
+ + + {ACTION_LABEL} Field + + + + + setName(e.target.value)} + /> + + + setLabel(e.target.value)} + /> + + + + + Cancel + + {ACTION_LABEL} Field + + + +
+
+ ); +}; 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 0000000000000..f8c6834db7805 --- /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/display_settings/result_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx new file mode 100644 index 0000000000000..cb65d8ef671e6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.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 from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiDragDropContext, + EuiDraggable, + EuiDroppable, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiTextColor, + EuiTitle, +} from '@elastic/eui'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { ExampleResultDetailCard } from './example_result_detail_card'; + +export const ResultDetail: React.FC = () => { + const { + toggleFieldEditorModal, + setDetailFields, + openEditDetailField, + removeDetailField, + } = useActions(DisplaySettingsLogic); + + const { + searchResultConfig: { detailFields }, + availableFieldOptions, + } = useValues(DisplaySettingsLogic); + + return ( + <> + + + + + + + <> + + + +

Visible Fields

+
+
+ + + Add Field + + +
+ + {detailFields.length > 0 ? ( + + + <> + {detailFields.map(({ fieldName, label }, index) => ( + + {(provided) => ( + + + +
+ +
+
+ + +

{fieldName}

+
+ +
“{label || ''}”
+
+
+ +
+ openEditDetailField(index)} + /> + removeDetailField(index)} + /> +
+
+
+
+ )} +
+ ))} + +
+
+ ) : ( +

Add fields and move them into the order you want them to appear.

+ )} + +
+
+
+ + + +

Preview

+
+ + +
+
+
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx new file mode 100644 index 0000000000000..cfe0ddb1533ec --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiColorPicker, + EuiFlexGrid, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiPanel, + EuiSelect, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { ExampleSearchResultGroup } from './example_search_result_group'; +import { ExampleStandoutResult } from './example_standout_result'; + +export const SearchResults: React.FC = () => { + const { + toggleTitleFieldHover, + toggleSubtitleFieldHover, + toggleDescriptionFieldHover, + setTitleField, + setSubtitleField, + setDescriptionField, + setUrlField, + setColorField, + } = useActions(DisplaySettingsLogic); + + const { + searchResultConfig: { titleField, descriptionField, subtitleField, urlField, color }, + fieldOptions, + optionalFieldOptions, + } = useValues(DisplaySettingsLogic); + + return ( + <> + + + + + +

Search Result Settings

+
+ + + null} // FIXME + onBlur={() => null} // FIXME + > + setTitleField(e.target.value)} + /> + + + setUrlField(e.target.value)} + /> + + + null} // FIXME + onBlur={() => null} // FIXME + /> + + null} // FIXME + onBlur={() => null} // FIXME + > + setSubtitleField(value === '' ? null : value)} + /> + + null} // FIXME + onBlur={() => null} // FIXME + > + + setDescriptionField(value === '' ? null : value) + } + /> + + +
+ + + +

Preview

+
+ +
+ +

Featured Results

+
+

+ A matching document will appear as a single bold card. +

+
+ + + +
+ +

Standard Results

+
+

+ Somewhat matching documents will appear as a set. +

+
+ + +
+
+
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx new file mode 100644 index 0000000000000..e27052ddffc04 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import classNames from 'classnames'; + +import { Result } from '../../../../types'; + +interface SubtitleFieldProps { + result: Result; + subtitleField: string | null; + subtitleFieldHover: boolean; +} + +export const SubtitleField: React.FC = ({ + result, + subtitleField, + subtitleFieldHover, +}) => ( +
+ {subtitleField ? ( +
{result[subtitleField]}
+ ) : ( + Subtitle + )} +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx new file mode 100644 index 0000000000000..a54c0977b464f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import classNames from 'classnames'; + +import { Result } from '../../../../types'; + +interface TitleFieldProps { + result: Result; + titleField: string | null; + titleFieldHover: boolean; +} + +export const TitleField: React.FC = ({ result, titleField, titleFieldHover }) => { + const title = titleField ? result[titleField] : ''; + const titleDisplay = Array.isArray(title) ? title.join(', ') : title; + return ( +
+ {titleField ? ( +
{titleDisplay}
+ ) : ( + Title + )} +
+ ); +}; diff --git a/x-pack/plugins/data_enhanced/common/search/es_search/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/index.ts similarity index 85% rename from x-pack/plugins/data_enhanced/common/search/es_search/index.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/index.ts index bbf9f14ba63c2..720ae8ac2a705 100644 --- a/x-pack/plugins/data_enhanced/common/search/es_search/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './es_search_rxjs_utils'; +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 0000000000000..55f1e1e03b2db --- /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 0000000000000..dd772b86a00e2 --- /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 0000000000000..cc68a62b9555d --- /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 0000000000000..f447751e96594 --- /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 0000000000000..ecc9c7d159131 --- /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 0000000000000..f1818c852f97f --- /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 0a11da02dc789..51b5735f01045 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 0000000000000..7161e613247cd --- /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 0000000000000..fb0cecc181487 --- /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 5a8da7cd32fa8..1757f2a6414f7 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 0000000000000..9f96a13e272d2 --- /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 0000000000000..7485f986076d7 --- /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 c0f8bf57989ca..cbfb22915c4eb 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 268e4f8da445a..64dc5149decd5 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 ed5136a6f7a4e..4957324aa6bd7 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 6614ac58b0744..06c620ad384e6 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 73cf4b419f944..49f0156ce481d 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 3d6d65fce2528..3b91c4e84d02f 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 f4ee8283122fd..94e9ea88ea755 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 9cf491b79fd24..62f4dceeac363 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 @@ -18,6 +18,7 @@ import { registerAccountPreSourceRoute, registerAccountPrepareSourcesRoute, registerAccountSourceSearchableRoute, + registerAccountSourceDisplaySettingsConfig, registerOrgSourcesRoute, registerOrgSourcesStatusRoute, registerOrgSourceRoute, @@ -29,6 +30,7 @@ import { registerOrgPreSourceRoute, registerOrgPrepareSourcesRoute, registerOrgSourceSearchableRoute, + registerOrgSourceDisplaySettingsConfig, registerOrgSourceOauthConfigurationsRoute, registerOrgSourceOauthConfigurationRoute, } from './sources'; @@ -328,10 +330,8 @@ describe('sources routes', () => { const mockRequest = { params: { id: '123' }, body: { - query: { - content_source: { - name: 'foo', - }, + content_source: { + name: 'foo', }, }, }; @@ -406,7 +406,7 @@ describe('sources routes', () => { mockRouter.callRoute(mockRequest); expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/ws/pre_content_sources/zendesk', + path: '/ws/sources/zendesk/prepare', }); }); }); @@ -448,6 +448,81 @@ describe('sources routes', () => { }); }); + describe('GET /api/workplace_search/account/sources/{id}/display_settings/config', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/account/sources/{id}/display_settings/config', + payload: 'params', + }); + + registerAccountSourceDisplaySettingsConfig({ + ...mockDependencies, + router: mockRouter.router, + }); + + const mockRequest = { + params: { + id: '123', + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/sources/123/display_settings/config', + }); + }); + }); + + describe('POST /api/workplace_search/account/sources/{id}/display_settings/config', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/account/sources/{id}/display_settings/config', + payload: 'body', + }); + + registerAccountSourceDisplaySettingsConfig({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + const mockRequest = { + params: { id: '123' }, + body: { + titleField: 'foo', + subtitleField: 'bar', + descriptionField: 'this is a thing', + urlField: 'http://youknowfor.search', + color: '#aaa', + detailFields: { + fieldName: 'myField', + label: 'My Field', + }, + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/sources/123/display_settings/config', + body: mockRequest.body, + }); + }); + }); + describe('GET /api/workplace_search/org/sources', () => { let mockRouter: MockRouter; @@ -732,10 +807,8 @@ describe('sources routes', () => { const mockRequest = { params: { id: '123' }, body: { - query: { - content_source: { - name: 'foo', - }, + content_source: { + name: 'foo', }, }, }; @@ -810,7 +883,7 @@ describe('sources routes', () => { mockRouter.callRoute(mockRequest); expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/ws/org/pre_content_sources/zendesk', + path: '/ws/org/sources/zendesk/prepare', }); }); }); @@ -852,6 +925,81 @@ describe('sources routes', () => { }); }); + describe('GET /api/workplace_search/org/sources/{id}/display_settings/config', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/sources/{id}/display_settings/config', + payload: 'params', + }); + + registerOrgSourceDisplaySettingsConfig({ + ...mockDependencies, + router: mockRouter.router, + }); + + const mockRequest = { + params: { + id: '123', + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/sources/123/display_settings/config', + }); + }); + }); + + describe('POST /api/workplace_search/org/sources/{id}/display_settings/config', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/org/sources/{id}/display_settings/config', + payload: 'body', + }); + + registerOrgSourceDisplaySettingsConfig({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + const mockRequest = { + params: { id: '123' }, + body: { + titleField: 'foo', + subtitleField: 'bar', + descriptionField: 'this is a thing', + urlField: 'http://youknowfor.search', + color: '#aaa', + detailFields: { + fieldName: 'myField', + label: 'My Field', + }, + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/sources/123/display_settings/config', + body: mockRequest.body, + }); + }); + }); + describe('GET /api/workplace_search/org/settings/connectors', () => { let mockRouter: MockRouter; 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 bdd048438dae5..d43a4252c7e1f 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 @@ -25,6 +25,21 @@ const oAuthConfigSchema = schema.object({ consumer_key: schema.string(), }); +const displayFieldSchema = schema.object({ + fieldName: schema.string(), + label: schema.string(), +}); + +const displaySettingsSchema = schema.object({ + titleField: schema.maybe(schema.string()), + subtitleField: schema.maybe(schema.string()), + descriptionField: schema.maybe(schema.string()), + urlField: schema.maybe(schema.string()), + color: schema.string(), + urlFieldIsLinkable: schema.boolean(), + detailFields: schema.oneOf([schema.arrayOf(displayFieldSchema), displayFieldSchema]), +}); + export function registerAccountSourcesRoute({ router, enterpriseSearchRequestHandler, @@ -200,10 +215,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 +269,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); } ); @@ -287,6 +300,45 @@ export function registerAccountSourceSearchableRoute({ ); } +export function registerAccountSourceDisplaySettingsConfig({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/account/sources/{id}/display_settings/config', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/sources/${request.params.id}/display_settings/config`, + })(context, request, response); + } + ); + + router.post( + { + path: '/api/workplace_search/account/sources/{id}/display_settings/config', + validate: { + body: displaySettingsSchema, + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/sources/${request.params.id}/display_settings/config`, + body: request.body, + })(context, request, response); + } + ); +} + export function registerOrgSourcesRoute({ router, enterpriseSearchRequestHandler, @@ -372,7 +424,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 +514,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 +568,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); } ); @@ -549,6 +599,45 @@ export function registerOrgSourceSearchableRoute({ ); } +export function registerOrgSourceDisplaySettingsConfig({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/sources/{id}/display_settings/config', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/sources/${request.params.id}/display_settings/config`, + })(context, request, response); + } + ); + + router.post( + { + path: '/api/workplace_search/org/sources/{id}/display_settings/config', + validate: { + body: displaySettingsSchema, + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/sources/${request.params.id}/display_settings/config`, + body: request.body, + })(context, request, response); + } + ); +} + export function registerOrgSourceOauthConfigurationsRoute({ router, enterpriseSearchRequestHandler, @@ -651,6 +740,7 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => { registerAccountPreSourceRoute(dependencies); registerAccountPrepareSourcesRoute(dependencies); registerAccountSourceSearchableRoute(dependencies); + registerAccountSourceDisplaySettingsConfig(dependencies); registerOrgSourcesRoute(dependencies); registerOrgSourcesStatusRoute(dependencies); registerOrgSourceRoute(dependencies); @@ -662,6 +752,7 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => { registerOrgPreSourceRoute(dependencies); registerOrgPrepareSourcesRoute(dependencies); registerOrgSourceSearchableRoute(dependencies); + registerOrgSourceDisplaySettingsConfig(dependencies); registerOrgSourceOauthConfigurationsRoute(dependencies); registerOrgSourceOauthConfigurationRoute(dependencies); }; diff --git a/x-pack/plugins/fleet/common/services/is_valid_namespace.test.ts b/x-pack/plugins/fleet/common/services/is_valid_namespace.test.ts index 8d60c4aa61dca..bfabe79f32110 100644 --- a/x-pack/plugins/fleet/common/services/is_valid_namespace.test.ts +++ b/x-pack/plugins/fleet/common/services/is_valid_namespace.test.ts @@ -8,7 +8,6 @@ import { isValidNamespace } from './is_valid_namespace'; describe('Fleet - isValidNamespace', () => { it('returns true for valid namespaces', () => { expect(isValidNamespace('default').valid).toBe(true); - expect(isValidNamespace('namespace-with-dash').valid).toBe(true); expect(isValidNamespace('123').valid).toBe(true); expect(isValidNamespace('testlength😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀').valid).toBe( true @@ -19,6 +18,7 @@ describe('Fleet - isValidNamespace', () => { expect(isValidNamespace('').valid).toBe(false); expect(isValidNamespace(' ').valid).toBe(false); expect(isValidNamespace('Default').valid).toBe(false); + expect(isValidNamespace('namespace-with-dash').valid).toBe(false); expect(isValidNamespace('namespace with spaces').valid).toBe(false); expect(isValidNamespace('foo/bar').valid).toBe(false); expect(isValidNamespace('foo\\bar').valid).toBe(false); diff --git a/x-pack/plugins/fleet/common/services/is_valid_namespace.ts b/x-pack/plugins/fleet/common/services/is_valid_namespace.ts index 8bd8349580edc..b70dc8ab67bb2 100644 --- a/x-pack/plugins/fleet/common/services/is_valid_namespace.ts +++ b/x-pack/plugins/fleet/common/services/is_valid_namespace.ts @@ -23,7 +23,7 @@ export function isValidNamespace(namespace: string): { valid: boolean; error?: s defaultMessage: 'Namespace must be lowercase', }), }; - } else if (/[\*\\/\?"<>|\s,#:]+/.test(namespace)) { + } else if (/[\*\\/\?"<>|\s,#:-]+/.test(namespace)) { return { valid: false, error: i18n.translate('xpack.fleet.namespaceValidation.invalidCharactersErrorMessage', { diff --git a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts index f721afb639141..a370f92e97fe1 100644 --- a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts +++ b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts @@ -100,7 +100,7 @@ describe('Fleet - storedPackagePoliciesToAgentInputs', () => { ).toEqual([]); }); - it('returns agent inputs', () => { + it('returns agent inputs with streams', () => { expect( storedPackagePoliciesToAgentInputs([ { @@ -143,6 +143,46 @@ describe('Fleet - storedPackagePoliciesToAgentInputs', () => { ]); }); + it('returns agent inputs without streams', () => { + expect( + storedPackagePoliciesToAgentInputs([ + { + ...mockPackagePolicy, + package: { + name: 'mock-package', + title: 'Mock package', + version: '0.0.0', + }, + inputs: [ + { + ...mockInput, + compiled_input: { + inputVar: 'input-value', + }, + streams: [], + }, + ], + }, + ]) + ).toEqual([ + { + id: 'some-uuid', + name: 'mock-package-policy', + revision: 1, + type: 'test-logs', + data_stream: { namespace: 'default' }, + use_output: 'default', + meta: { + package: { + name: 'mock-package', + version: '0.0.0', + }, + }, + inputVar: 'input-value', + }, + ]); + }); + it('returns agent inputs without disabled streams', () => { expect( storedPackagePoliciesToAgentInputs([ diff --git a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts index e74256ce732a6..d780fb791aa8e 100644 --- a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts +++ b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts @@ -33,20 +33,25 @@ export const storedPackagePoliciesToAgentInputs = ( acc[key] = value; return acc; }, {} as { [k: string]: any }), - streams: input.streams - .filter((stream) => stream.enabled) - .map((stream) => { - const fullStream: FullAgentPolicyInputStream = { - id: stream.id, - data_stream: stream.data_stream, - ...stream.compiled_stream, - ...Object.entries(stream.config || {}).reduce((acc, [key, { value }]) => { - acc[key] = value; - return acc; - }, {} as { [k: string]: any }), - }; - return fullStream; - }), + ...(input.compiled_input || {}), + ...(input.streams.length + ? { + streams: input.streams + .filter((stream) => stream.enabled) + .map((stream) => { + const fullStream: FullAgentPolicyInputStream = { + id: stream.id, + data_stream: stream.data_stream, + ...stream.compiled_stream, + ...Object.entries(stream.config || {}).reduce((acc, [key, { value }]) => { + acc[key] = value; + return acc; + }, {} as { [k: string]: any }), + }; + return fullStream; + }), + } + : {}), }; if (packagePolicy.package) { diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index f35b6b3f7de6a..4af3f3beb32be 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/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index f43f65fb317f3..75bb2998f2d92 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -49,7 +49,7 @@ export interface FullAgentPolicyInput { package?: Pick; [key: string]: unknown; }; - streams: FullAgentPolicyInputStream[]; + streams?: FullAgentPolicyInputStream[]; [key: string]: any; } diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 7a6f6232b2d4f..53e507f6fb494 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -121,6 +121,7 @@ export interface RegistryInput { title: string; description?: string; vars?: RegistryVarsEntry[]; + template_path?: string; } export interface RegistryStream { diff --git a/x-pack/plugins/fleet/common/types/models/package_policy.ts b/x-pack/plugins/fleet/common/types/models/package_policy.ts index ae16899a4b6f9..6da98a51ef1ff 100644 --- a/x-pack/plugins/fleet/common/types/models/package_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/package_policy.ts @@ -42,6 +42,7 @@ export interface NewPackagePolicyInput { export interface PackagePolicyInput extends Omit { streams: PackagePolicyInputStream[]; + compiled_input?: any; } export interface NewPackagePolicy { 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 93bc0645c7eee..ea6abc4bba5f5 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 0000000000000..a9e44b200cf69 --- /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 6026a5579f65b..5b0243f127333 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 699bba3c62f97..1fdd223ef8047 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 564e7b225cf45..7bbf621c57894 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 0000000000000..f9c351899fe0a --- /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/components/package_policy_input_config.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx index 75000ad7e1d3b..9015cd09f78a3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx @@ -27,6 +27,7 @@ const FlexItemWithMaxWidth = styled(EuiFlexItem)` `; export const PackagePolicyInputConfig: React.FunctionComponent<{ + hasInputStreams: boolean; packageInputVars?: RegistryVarsEntry[]; packagePolicyInput: NewPackagePolicyInput; updatePackagePolicyInput: (updatedInput: Partial) => void; @@ -34,6 +35,7 @@ export const PackagePolicyInputConfig: React.FunctionComponent<{ forceShowErrors?: boolean; }> = memo( ({ + hasInputStreams, packageInputVars, packagePolicyInput, updatePackagePolicyInput, @@ -82,15 +84,19 @@ export const PackagePolicyInputConfig: React.FunctionComponent<{ /> - - -

- -

-
+ {hasInputStreams ? ( + <> + + +

+ +

+
+ + ) : null} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.tsx index 79ff0cc29850c..8e242980ce807 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.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, { useState, Fragment, memo } from 'react'; +import React, { useState, Fragment, memo, useMemo } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -85,16 +85,23 @@ export const PackagePolicyInputPanel: React.FunctionComponent<{ const errorCount = countValidationErrors(inputValidationResults); const hasErrors = forceShowErrors && errorCount; - const inputStreams = packageInputStreams - .map((packageInputStream) => { - return { - packageInputStream, - packagePolicyInputStream: packagePolicyInput.streams.find( - (stream) => stream.data_stream.dataset === packageInputStream.data_stream.dataset - ), - }; - }) - .filter((stream) => Boolean(stream.packagePolicyInputStream)); + const hasInputStreams = useMemo(() => packageInputStreams.length > 0, [ + packageInputStreams.length, + ]); + const inputStreams = useMemo( + () => + packageInputStreams + .map((packageInputStream) => { + return { + packageInputStream, + packagePolicyInputStream: packagePolicyInput.streams.find( + (stream) => stream.data_stream.dataset === packageInputStream.data_stream.dataset + ), + }; + }) + .filter((stream) => Boolean(stream.packagePolicyInputStream)), + [packageInputStreams, packagePolicyInput.streams] + ); return ( <> @@ -179,13 +186,14 @@ export const PackagePolicyInputPanel: React.FunctionComponent<{ {isShowingStreams && packageInput.vars && packageInput.vars.length ? ( - + {hasInputStreams ? : } ) : null} 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 62792b84105ab..a68dbe52555ff 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 9c94bb939cdf8..53463c14b9ce6 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 8c2fe838bfa43..0e8bb6b49e4ab 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 5ce757734e637..1b6ad35cc6424 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/agent_logs.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx new file mode 100644 index 0000000000000..00deeff89503f --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx @@ -0,0 +1,278 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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, useMemo, useState, useCallback, useEffect } from 'react'; +import styled from 'styled-components'; +import url from 'url'; +import { encode } from 'rison-node'; +import { stringify } from 'query-string'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSuperDatePicker, + EuiFilterGroup, + EuiPanel, + EuiButtonEmpty, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import semverGte from 'semver/functions/gte'; +import semverCoerce from 'semver/functions/coerce'; +import { createStateContainerReactHelpers } from '../../../../../../../../../../../src/plugins/kibana_utils/public'; +import { RedirectAppLinks } from '../../../../../../../../../../../src/plugins/kibana_react/public'; +import { TimeRange, esKuery } from '../../../../../../../../../../../src/plugins/data/public'; +import { LogStream } from '../../../../../../../../../infra/public'; +import { Agent } from '../../../../../types'; +import { useStartServices } from '../../../../../hooks'; +import { DEFAULT_DATE_RANGE } from './constants'; +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%; +`; + +const DatePickerFlexItem = styled(EuiFlexItem)` + max-width: 312px; +`; + +export interface AgentLogsProps { + agent: Agent; + state: AgentLogsState; +} + +export interface AgentLogsState { + start: string; + end: string; + logLevels: string[]; + datasets: string[]; + query: string; +} + +export const AgentLogsUrlStateHelper = createStateContainerReactHelpers(); + +export const AgentLogsUI: React.FunctionComponent = memo(({ agent, state }) => { + const { data, application, http } = useStartServices(); + const { update: updateState } = AgentLogsUrlStateHelper.useTransitions(); + + // Util to convert date expressions (returned by datepicker) to timestamps (used by LogStream) + const getDateRangeTimestamps = useCallback( + (timeRange: TimeRange) => { + const { min, max } = data.query.timefilter.timefilter.calculateBounds(timeRange); + return min && max + ? { + start: min.valueOf(), + end: max.valueOf(), + } + : undefined; + }, + [data.query.timefilter.timefilter] + ); + + const tryUpdateDateRange = useCallback( + (timeRange: TimeRange) => { + const timestamps = getDateRangeTimestamps(timeRange); + if (timestamps) { + updateState({ + start: timeRange.from, + end: timeRange.to, + }); + } + }, + [getDateRangeTimestamps, updateState] + ); + + const [dateRangeTimestamps, setDateRangeTimestamps] = useState<{ start: number; end: number }>( + getDateRangeTimestamps({ + from: state.start, + to: state.end, + }) || + getDateRangeTimestamps({ + from: DEFAULT_DATE_RANGE.start, + to: DEFAULT_DATE_RANGE.end, + })! + ); + + // Attempts to parse for timestamps when start/end date expressions change + // If invalid date expressions, set expressions back to default + // Otherwise set the new timestamps + useEffect(() => { + const timestampsFromDateRange = getDateRangeTimestamps({ + from: state.start, + to: state.end, + }); + if (!timestampsFromDateRange) { + tryUpdateDateRange({ + from: DEFAULT_DATE_RANGE.start, + to: DEFAULT_DATE_RANGE.end, + }); + } else { + setDateRangeTimestamps(timestampsFromDateRange); + } + }, [state.start, state.end, getDateRangeTimestamps, tryUpdateDateRange]); + + // Query validation helper + const isQueryValid = useCallback((testQuery: string) => { + try { + esKuery.fromKueryExpression(testQuery); + return true; + } catch (err) { + return false; + } + }, []); + + // User query state + const [draftQuery, setDraftQuery] = useState(state.query); + const [isDraftQueryValid, setIsDraftQueryValid] = useState(isQueryValid(state.query)); + const onUpdateDraftQuery = useCallback( + (newDraftQuery: string, runQuery?: boolean) => { + setDraftQuery(newDraftQuery); + if (isQueryValid(newDraftQuery)) { + setIsDraftQueryValid(true); + if (runQuery) { + updateState({ query: newDraftQuery }); + } + } else { + setIsDraftQueryValid(false); + } + }, + [isQueryValid, updateState] + ); + + // Build final log stream query from agent id, datasets, log levels, and user input + const logStreamQuery = useMemo( + () => + buildQuery({ + agentId: agent.id, + datasets: state.datasets, + logLevels: state.logLevels, + userQuery: state.query, + }), + [agent.id, state.datasets, state.logLevels, state.query] + ); + + // Generate URL to pass page state to Logs UI + const viewInLogsUrl = useMemo( + () => + http.basePath.prepend( + url.format({ + pathname: '/app/logs/stream', + search: stringify( + { + logPosition: encode({ + start: state.start, + end: state.end, + streamLive: false, + }), + logFilter: encode({ + expression: logStreamQuery, + kind: 'kuery', + }), + }, + { sort: false, encode: false } + ), + }) + ), + [http.basePath, state.start, state.end, logStreamQuery] + ); + + 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 ( + + + + + + + + + { + const currentDatasets = [...state.datasets]; + const datasetPosition = currentDatasets.indexOf(dataset); + if (datasetPosition >= 0) { + currentDatasets.splice(datasetPosition, 1); + updateState({ datasets: currentDatasets }); + } else { + updateState({ datasets: [...state.datasets, dataset] }); + } + }} + /> + { + const currentLevels = [...state.logLevels]; + const levelPosition = currentLevels.indexOf(level); + if (levelPosition >= 0) { + currentLevels.splice(levelPosition, 1); + updateState({ logLevels: currentLevels }); + } else { + updateState({ logLevels: [...state.logLevels, level] }); + } + }} + /> + + + + { + tryUpdateDateRange({ + from: start, + to: end, + }); + }} + /> + + + + + + + + + + + + + + + + {isLogLevelSelectionAvailable && ( + + + + )} + + ); +}); 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 b56e27356ef34..89fe1a916605d 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 @@ -3,6 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { AgentLogsState } from './agent_logs'; + export const AGENT_LOG_INDEX_PATTERN = 'logs-elastic_agent-*,logs-elastic_agent.*-*'; export const AGENT_DATASET = 'elastic_agent'; export const AGENT_DATASET_PATTERN = 'elastic_agent.*'; @@ -24,3 +26,21 @@ export const DEFAULT_DATE_RANGE = { start: 'now-1d', end: 'now', }; +export const DEFAULT_LOGS_STATE: AgentLogsState = { + start: DEFAULT_DATE_RANGE.start, + end: DEFAULT_DATE_RANGE.end, + logLevels: [], + datasets: [AGENT_DATASET], + query: '', +}; + +export const AGENT_LOG_LEVELS = { + ERROR: 'error', + WARNING: 'warning', + INFO: 'info', + DEBUG: 'debug', +}; + +export const ORDERED_FILTER_LOG_LEVELS = ['error', 'warning', 'warn', 'notice', 'info', '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 b034168dc8a15..6aee9e065a96d 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,19 @@ import React, { memo, useState, useEffect } from 'react'; import { EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { ORDERED_FILTER_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'; + +function sortLogLevels(levels: string[]): string[] { + return [ + ...new Set([ + // order by severity for known level + ...ORDERED_FILTER_LOG_LEVELS.filter((level) => levels.includes(level)), + // Add unknown log level + ...levels.sort(), + ]), + ]; +} export const LogLevelFilter: React.FunctionComponent<{ selectedLevels: string[]; @@ -22,7 +33,7 @@ export const LogLevelFilter: React.FunctionComponent<{ 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 +41,7 @@ export const LogLevelFilter: React.FunctionComponent<{ field: LOG_LEVEL_FIELD, query: '', }); - setLevelValues(values.sort()); + setLevelValues(sortLogLevels(values)); } 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 e033781a850a0..0d888a88ec2cb 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 @@ -3,216 +3,63 @@ * 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, useMemo, useState, useCallback } from 'react'; -import styled from 'styled-components'; -import url from 'url'; -import { encode } from 'rison-node'; -import { stringify } from 'query-string'; +import React, { memo, useEffect, useState } from 'react'; import { - EuiFlexGroup, - EuiFlexItem, - EuiSuperDatePicker, - EuiFilterGroup, - EuiPanel, - EuiButtonEmpty, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { RedirectAppLinks } from '../../../../../../../../../../../src/plugins/kibana_react/public'; -import { TimeRange, esKuery } from '../../../../../../../../../../../src/plugins/data/public'; -import { LogStream } from '../../../../../../../../../infra/public'; -import { Agent } from '../../../../../types'; -import { useStartServices } from '../../../../../hooks'; -import { AGENT_DATASET, DEFAULT_DATE_RANGE } from './constants'; -import { DatasetFilter } from './filter_dataset'; -import { LogLevelFilter } from './filter_log_level'; -import { LogQueryBar } from './query_bar'; -import { buildQuery } from './build_query'; + createStateContainer, + syncState, + createKbnUrlStateStorage, + INullableBaseStateContainer, + PureTransition, + getStateFromKbnUrl, +} from '../../../../../../../../../../../src/plugins/kibana_utils/public'; +import { DEFAULT_LOGS_STATE } from './constants'; +import { AgentLogsUI, AgentLogsProps, AgentLogsState, AgentLogsUrlStateHelper } from './agent_logs'; -const WrapperFlexGroup = styled(EuiFlexGroup)` - height: 100%; -`; +const stateStorageKey = '_q'; -const DatePickerFlexItem = styled(EuiFlexItem)` - max-width: 312px; -`; +const stateContainer = createStateContainer< + AgentLogsState, + { + update: PureTransition]>; + } +>( + { + ...DEFAULT_LOGS_STATE, + ...getStateFromKbnUrl(stateStorageKey, window.location.href), + }, + { + update: (state) => (updatedState) => ({ ...state, ...updatedState }), + } +); -export const AgentLogs: React.FunctionComponent<{ agent: Agent }> = memo(({ agent }) => { - const { data, application, http } = useStartServices(); +const AgentLogsConnected = AgentLogsUrlStateHelper.connect((state) => ({ + state: state || DEFAULT_LOGS_STATE, +}))(AgentLogsUI); - // Util to convert date expressions (returned by datepicker) to timestamps (used by LogStream) - const getDateRangeTimestamps = useCallback( - (timeRange: TimeRange) => { - const { min, max } = data.query.timefilter.timefilter.calculateBounds(timeRange); - return min && max - ? { - startTimestamp: min.valueOf(), - endTimestamp: max.valueOf(), - } - : undefined; - }, - [data.query.timefilter.timefilter] - ); +export const AgentLogs: React.FunctionComponent> = memo( + ({ agent }) => { + const [isSyncReady, setIsSyncReady] = useState(false); - // Initial time range filter - const [dateRange, setDateRange] = useState<{ - startExpression: string; - endExpression: string; - startTimestamp: number; - endTimestamp: number; - }>({ - startExpression: DEFAULT_DATE_RANGE.start, - endExpression: DEFAULT_DATE_RANGE.end, - ...getDateRangeTimestamps({ from: DEFAULT_DATE_RANGE.start, to: DEFAULT_DATE_RANGE.end })!, - }); + useEffect(() => { + const stateStorage = createKbnUrlStateStorage(); + const { start, stop } = syncState({ + storageKey: stateStorageKey, + stateContainer: stateContainer as INullableBaseStateContainer, + stateStorage, + }); + start(); + setIsSyncReady(true); - const tryUpdateDateRange = useCallback( - (timeRange: TimeRange) => { - const timestamps = getDateRangeTimestamps(timeRange); - if (timestamps) { - setDateRange({ - startExpression: timeRange.from, - endExpression: timeRange.to, - ...timestamps, - }); - } - }, - [getDateRangeTimestamps] - ); + return () => { + stop(); + stateContainer.set(DEFAULT_LOGS_STATE); + }; + }, []); - // Filters - const [selectedLogLevels, setSelectedLogLevels] = useState([]); - const [selectedDatasets, setSelectedDatasets] = useState([AGENT_DATASET]); - - // User query state - const [query, setQuery] = useState(''); - const [draftQuery, setDraftQuery] = useState(''); - const [isDraftQueryValid, setIsDraftQueryValid] = useState(true); - const onUpdateDraftQuery = useCallback((newDraftQuery: string, runQuery?: boolean) => { - setDraftQuery(newDraftQuery); - try { - esKuery.fromKueryExpression(newDraftQuery); - setIsDraftQueryValid(true); - if (runQuery) { - setQuery(newDraftQuery); - } - } catch (err) { - setIsDraftQueryValid(false); - } - }, []); - - // Build final log stream query from agent id, datasets, log levels, and user input - const logStreamQuery = useMemo( - () => - buildQuery({ - agentId: agent.id, - datasets: selectedDatasets, - logLevels: selectedLogLevels, - userQuery: query, - }), - [agent.id, query, selectedDatasets, selectedLogLevels] - ); - - // Generate URL to pass page state to Logs UI - const viewInLogsUrl = useMemo( - () => - http.basePath.prepend( - url.format({ - pathname: '/app/logs/stream', - search: stringify( - { - logPosition: encode({ - start: dateRange.startExpression, - end: dateRange.endExpression, - streamLive: false, - }), - logFilter: encode({ - expression: logStreamQuery, - kind: 'kuery', - }), - }, - { sort: false, encode: false } - ), - }) - ), - [logStreamQuery, dateRange.endExpression, dateRange.startExpression, http.basePath] - ); - - return ( - - - - - - - - - { - const currentLevels = [...selectedDatasets]; - const levelPosition = currentLevels.indexOf(level); - if (levelPosition >= 0) { - currentLevels.splice(levelPosition, 1); - setSelectedDatasets(currentLevels); - } else { - setSelectedDatasets([...selectedDatasets, level]); - } - }} - /> - { - const currentLevels = [...selectedLogLevels]; - const levelPosition = currentLevels.indexOf(level); - if (levelPosition >= 0) { - currentLevels.splice(levelPosition, 1); - setSelectedLogLevels(currentLevels); - } else { - setSelectedLogLevels([...selectedLogLevels, level]); - } - }} - /> - - - - { - tryUpdateDateRange({ - from: start, - to: end, - }); - }} - /> - - - - - - - - - - - - - - - - - ); -}); + return ( + + {isSyncReady ? : null} + + ); + } +); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/query_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/query_bar.tsx index ae2385d714219..ab9e7cafb7c01 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/query_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/query_bar.tsx @@ -47,6 +47,8 @@ export const LogQueryBar: React.FunctionComponent<{ return ( = 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 40346cde7f50f..62adad14a028c 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 0e72693db9e2d..aad8f9701923e 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 c329596384730..cbfab9ac9e5d2 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 d97d891ac5e5d..8609b08c9a774 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 0000000000000..06b3c7a9a4093 --- /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 0000000000000..d8a9d18e8a21f --- /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 78cb355318d40..ded1447954aff 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 18ce12794776a..3babb03c2dac6 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 8fb4924a2ccf0..f26e4261d573e 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 e7d95a7e83773..35517e6a7a700 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/mocks.ts b/x-pack/plugins/fleet/server/mocks.ts index 91098c87c312a..bc3e89ef6d3ce 100644 --- a/x-pack/plugins/fleet/server/mocks.ts +++ b/x-pack/plugins/fleet/server/mocks.ts @@ -25,7 +25,7 @@ export const createAppContextStartContractMock = (): FleetAppContext => { export const createPackagePolicyServiceMock = () => { return { - assignPackageStream: jest.fn(), + compilePackagePolicyInputs: jest.fn(), buildPackagePolicyFromPackage: jest.fn(), bulkCreate: jest.fn(), create: jest.fn(), diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 90fb34efd4817..716939c28bf1e 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/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index f47b8499a1b69..fee74e39c833a 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -22,7 +22,7 @@ jest.mock('../../services/package_policy', (): { } => { return { packagePolicyService: { - assignPackageStream: jest.fn((packageInfo, dataInputs) => Promise.resolve(dataInputs)), + compilePackagePolicyInputs: jest.fn((packageInfo, dataInputs) => Promise.resolve(dataInputs)), buildPackagePolicyFromPackage: jest.fn(), bulkCreate: jest.fn(), create: jest.fn((soClient, callCluster, newData) => diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 9d85a151efbbf..201ca1c7a97bc 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -242,6 +242,7 @@ const getSavedObjectTypes = ( enabled: { type: 'boolean' }, vars: { type: 'flattened' }, config: { type: 'flattened' }, + compiled_input: { type: 'flattened' }, streams: { type: 'nested', properties: { 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 28ddf9704bd92..9c87eaa1859cd 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/agent/agent.test.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts index 54b40400bb4e7..dba6f442d76e2 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createStream } from './agent'; +import { compileTemplate } from './agent'; -describe('createStream', () => { +describe('compileTemplate', () => { it('should work', () => { const streamTemplate = ` input: log @@ -27,7 +27,7 @@ hidden_password: {{password}} password: { type: 'password', value: '' }, }; - const output = createStream(vars, streamTemplate); + const output = compileTemplate(vars, streamTemplate); expect(output).toEqual({ input: 'log', paths: ['/usr/local/var/log/nginx/access.log'], @@ -67,7 +67,7 @@ foo: bar password: { type: 'password', value: '' }, }; - const output = createStream(vars, streamTemplate); + const output = compileTemplate(vars, streamTemplate); expect(output).toEqual({ input: 'redis/metrics', metricsets: ['key'], @@ -114,7 +114,7 @@ hidden_password: {{password}} tags: { value: ['foo', 'bar', 'forwarded'] }, }; - const output = createStream(vars, streamTemplate); + const output = compileTemplate(vars, streamTemplate); expect(output).toEqual({ input: 'log', paths: ['/usr/local/var/log/nginx/access.log'], @@ -133,7 +133,7 @@ hidden_password: {{password}} tags: { value: ['foo', 'bar'] }, }; - const output = createStream(vars, streamTemplate); + const output = compileTemplate(vars, streamTemplate); expect(output).toEqual({ input: 'log', paths: ['/usr/local/var/log/nginx/access.log'], @@ -157,7 +157,7 @@ input: logs }, }; - const output = createStream(vars, streamTemplate); + const output = compileTemplate(vars, streamTemplate); expect(output).toEqual({ input: 'logs', }); diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts index eeadac6e168b1..400a688722f99 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts @@ -10,27 +10,30 @@ import { PackagePolicyConfigRecord } from '../../../../common'; const handlebars = Handlebars.create(); -export function createStream(variables: PackagePolicyConfigRecord, streamTemplate: string) { - const { vars, yamlValues } = buildTemplateVariables(variables, streamTemplate); +export function compileTemplate(variables: PackagePolicyConfigRecord, templateStr: string) { + const { vars, yamlValues } = buildTemplateVariables(variables, templateStr); - const template = handlebars.compile(streamTemplate, { noEscape: true }); - let stream = template(vars); - stream = replaceRootLevelYamlVariables(yamlValues, stream); + const template = handlebars.compile(templateStr, { noEscape: true }); + let compiledTemplate = template(vars); + compiledTemplate = replaceRootLevelYamlVariables(yamlValues, compiledTemplate); - const yamlFromStream = safeLoad(stream, {}); + const yamlFromCompiledTemplate = safeLoad(compiledTemplate, {}); // Hack to keep empty string ('') values around in the end yaml because // `safeLoad` replaces empty strings with null - const patchedYamlFromStream = Object.entries(yamlFromStream).reduce((acc, [key, value]) => { - if (value === null && typeof vars[key] === 'string' && vars[key].trim() === '') { - acc[key] = ''; - } else { - acc[key] = value; - } - return acc; - }, {} as { [k: string]: any }); + const patchedYamlFromCompiledTemplate = Object.entries(yamlFromCompiledTemplate).reduce( + (acc, [key, value]) => { + if (value === null && typeof vars[key] === 'string' && vars[key].trim() === '') { + acc[key] = ''; + } else { + acc[key] = value; + } + return acc; + }, + {} as { [k: string]: any } + ); - return replaceVariablesInYaml(yamlValues, patchedYamlFromStream); + return replaceVariablesInYaml(yamlValues, patchedYamlFromCompiledTemplate); } function isValidKey(key: string) { @@ -54,7 +57,7 @@ function replaceVariablesInYaml(yamlVariables: { [k: string]: any }, yaml: any) return yaml; } -function buildTemplateVariables(variables: PackagePolicyConfigRecord, streamTemplate: string) { +function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateStr: string) { const yamlValues: { [k: string]: any } = {}; const vars = Object.entries(variables).reduce((acc, [key, recordEntry]) => { // support variables with . like key.patterns 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 6ac81a25dfc21..1e8f7ce416df1 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/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index 6ae76c56436d5..30a980ab07f70 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -25,7 +25,16 @@ paths: }, ]; } - return []; + return [ + { + buffer: Buffer.from(` +hosts: +{{#each hosts}} +- {{this}} +{{/each}} +`), + }, + ]; } jest.mock('./epm/packages/assets', () => { @@ -47,9 +56,9 @@ jest.mock('./epm/registry', () => { }); describe('Package policy service', () => { - describe('assignPackageStream', () => { + describe('compilePackagePolicyInputs', () => { it('should work with config variables from the stream', async () => { - const inputs = await packagePolicyService.assignPackageStream( + const inputs = await packagePolicyService.compilePackagePolicyInputs( ({ data_streams: [ { @@ -110,7 +119,7 @@ describe('Package policy service', () => { }); it('should work with config variables at the input level', async () => { - const inputs = await packagePolicyService.assignPackageStream( + const inputs = await packagePolicyService.compilePackagePolicyInputs( ({ data_streams: [ { @@ -169,6 +178,117 @@ describe('Package policy service', () => { }, ]); }); + + it('should work with an input with a template and no streams', async () => { + const inputs = await packagePolicyService.compilePackagePolicyInputs( + ({ + data_streams: [], + policy_templates: [ + { + inputs: [{ type: 'log', template_path: 'some_template_path.yml' }], + }, + ], + } as unknown) as PackageInfo, + [ + { + type: 'log', + enabled: true, + vars: { + hosts: { + value: ['localhost'], + }, + }, + streams: [], + }, + ] + ); + + expect(inputs).toEqual([ + { + type: 'log', + enabled: true, + vars: { + hosts: { + value: ['localhost'], + }, + }, + compiled_input: { + hosts: ['localhost'], + }, + streams: [], + }, + ]); + }); + + it('should work with an input with a template and streams', async () => { + const inputs = await packagePolicyService.compilePackagePolicyInputs( + ({ + data_streams: [ + { + dataset: 'package.dataset1', + type: 'logs', + streams: [{ input: 'log', template_path: 'some_template_path.yml' }], + }, + ], + policy_templates: [ + { + inputs: [{ type: 'log', template_path: 'some_template_path.yml' }], + }, + ], + } as unknown) as PackageInfo, + [ + { + type: 'log', + enabled: true, + vars: { + hosts: { + value: ['localhost'], + }, + paths: { + value: ['/var/log/set.log'], + }, + }, + streams: [ + { + id: 'datastream01', + data_stream: { dataset: 'package.dataset1', type: 'logs' }, + enabled: true, + }, + ], + }, + ] + ); + + expect(inputs).toEqual([ + { + type: 'log', + enabled: true, + vars: { + hosts: { + value: ['localhost'], + }, + paths: { + value: ['/var/log/set.log'], + }, + }, + compiled_input: { + hosts: ['localhost'], + }, + streams: [ + { + id: 'datastream01', + data_stream: { dataset: 'package.dataset1', type: 'logs' }, + enabled: true, + compiled_stream: { + metricset: ['dataset1'], + paths: ['/var/log/set.log'], + type: 'log', + }, + }, + ], + }, + ]); + }); }); describe('update', () => { diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 0f78c97a6f2bd..7b8952bdea2cd 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -31,7 +31,7 @@ import { outputService } from './output'; import * as Registry from './epm/registry'; import { getPackageInfo, getInstallation, ensureInstalledPackage } from './epm/packages'; import { getAssetsData } from './epm/packages/assets'; -import { createStream } from './epm/agent/agent'; +import { compileTemplate } from './epm/agent/agent'; import { normalizeKuery } from './saved_object'; const SAVED_OBJECT_TYPE = PACKAGE_POLICY_SAVED_OBJECT_TYPE; @@ -92,7 +92,7 @@ class PackagePolicyService { } } - inputs = await this.assignPackageStream(pkgInfo, inputs); + inputs = await this.compilePackagePolicyInputs(pkgInfo, inputs); } const isoDate = new Date().toISOString(); @@ -285,7 +285,7 @@ class PackagePolicyService { pkgVersion: packagePolicy.package.version, }); - inputs = await this.assignPackageStream(pkgInfo, inputs); + inputs = await this.compilePackagePolicyInputs(pkgInfo, inputs); } await soClient.update( @@ -374,14 +374,20 @@ class PackagePolicyService { } } - public async assignPackageStream( + public async compilePackagePolicyInputs( pkgInfo: PackageInfo, inputs: PackagePolicyInput[] ): Promise { const registryPkgInfo = await Registry.fetchInfo(pkgInfo.name, pkgInfo.version); - const inputsPromises = inputs.map((input) => - _assignPackageStreamToInput(registryPkgInfo, pkgInfo, input) - ); + const inputsPromises = inputs.map(async (input) => { + const compiledInput = await _compilePackagePolicyInput(registryPkgInfo, pkgInfo, input); + const compiledStreams = await _compilePackageStreams(registryPkgInfo, pkgInfo, input); + return { + ...input, + compiled_input: compiledInput, + streams: compiledStreams, + }; + }); return Promise.all(inputsPromises); } @@ -396,20 +402,53 @@ function assignStreamIdToInput(packagePolicyId: string, input: NewPackagePolicyI }; } -async function _assignPackageStreamToInput( +async function _compilePackagePolicyInput( + registryPkgInfo: RegistryPackage, + pkgInfo: PackageInfo, + input: PackagePolicyInput +) { + if (!input.enabled || !pkgInfo.policy_templates?.[0].inputs) { + return undefined; + } + + const packageInputs = pkgInfo.policy_templates[0].inputs; + const packageInput = packageInputs.find((pkgInput) => pkgInput.type === input.type); + if (!packageInput) { + throw new Error(`Input template not found, unable to find input type ${input.type}`); + } + + if (!packageInput.template_path) { + return undefined; + } + + const [pkgInputTemplate] = await getAssetsData(registryPkgInfo, (path: string) => + path.endsWith(`/agent/input/${packageInput.template_path!}`) + ); + + if (!pkgInputTemplate || !pkgInputTemplate.buffer) { + throw new Error(`Unable to load input template at /agent/input/${packageInput.template_path!}`); + } + + return compileTemplate( + // Populate template variables from input vars + Object.assign({}, input.vars), + pkgInputTemplate.buffer.toString() + ); +} + +async function _compilePackageStreams( registryPkgInfo: RegistryPackage, pkgInfo: PackageInfo, input: PackagePolicyInput ) { const streamsPromises = input.streams.map((stream) => - _assignPackageStreamToStream(registryPkgInfo, pkgInfo, input, stream) + _compilePackageStream(registryPkgInfo, pkgInfo, input, stream) ); - const streams = await Promise.all(streamsPromises); - return { ...input, streams }; + return await Promise.all(streamsPromises); } -async function _assignPackageStreamToStream( +async function _compilePackageStream( registryPkgInfo: RegistryPackage, pkgInfo: PackageInfo, input: PackagePolicyInput, @@ -442,22 +481,22 @@ async function _assignPackageStreamToStream( throw new Error(`Stream template path not found for dataset ${datasetPath}`); } - const [pkgStream] = await getAssetsData( + const [pkgStreamTemplate] = await getAssetsData( registryPkgInfo, (path: string) => path.endsWith(streamFromPkg.template_path), datasetPath ); - if (!pkgStream || !pkgStream.buffer) { + if (!pkgStreamTemplate || !pkgStreamTemplate.buffer) { throw new Error( `Unable to load stream template ${streamFromPkg.template_path} for dataset ${datasetPath}` ); } - const yaml = createStream( + const yaml = compileTemplate( // Populate template variables from input vars and stream vars Object.assign({}, input.vars, stream.vars), - pkgStream.buffer.toString() + pkgStreamTemplate.buffer.toString() ); stream.compiled_stream = yaml; diff --git a/x-pack/plugins/global_search/common/types.ts b/x-pack/plugins/global_search/common/types.ts index a08ecaf41b213..7cc1d7ada4422 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 18483cea72540..0e1cbaedae782 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 f62acd08633ff..4794c355a161b 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 3c06dfab9f50e..7508c8db57165 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 350547a928fe4..419ad847d6c29 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 62b347d925868..64bd2fd6c930f 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 42ef234504d12..2707a2fded222 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 a9063abda0e3e..0b82a035348ed 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 ed28786782c35..c37bcdbf84743 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 2460100a46dbb..c8d656a524e94 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 1897a24196cf1..9ea62abac704c 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 07d21f54d7bf5..0878a965ea8c3 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 bf0ae83a0d863..85e091fe1abad 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 bf7eacd2b52a1..de45d8ea5dfaf 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 a3e2d66eabe5b..5ba00c293d213 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 adc55329962e9..3746e636066a9 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 14ac0935467d7..81951843ee8b5 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 0000000000000..01c52e468af3a --- /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 0000000000000..3b00389b8605d --- /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 0000000000000..83117ddfb507d --- /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 0000000000000..c04f5dddd34a2 --- /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 0000000000000..93fdd943a202c --- /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 0000000000000..8df025a478bc5 --- /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 8acbda5e0a6d4..2831550da00d9 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 45264a3b2c521..fd6eb0dc1878b 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 8798fe6694c96..ca5dbf8026472 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 14641e1aaffff..ec55a2a78fa9e 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 b556e2785b4b4..da9276278dbbf 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 3861858a53626..3e2c42e7896fd 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/index_lifecycle_management/server/routes/api/policies/register_create_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts index d8e40e3b30410..8d21b1f164541 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts @@ -23,114 +23,20 @@ async function createPolicy(client: ElasticsearchClient, name: string, phases: a return client.ilm.putLifecycle({ policy: name, body }, options); } -const minAgeSchema = schema.maybe(schema.string()); - -const setPrioritySchema = schema.maybe( - schema.object({ - priority: schema.nullable(schema.number()), - }) -); - -const unfollowSchema = schema.maybe(schema.object({})); // Unfollow has no options - -const migrateSchema = schema.maybe(schema.object({ enabled: schema.literal(false) })); - -const allocateNodeSchema = schema.maybe(schema.recordOf(schema.string(), schema.string())); -const allocateSchema = schema.maybe( - schema.object({ - number_of_replicas: schema.maybe(schema.number()), - include: allocateNodeSchema, - exclude: allocateNodeSchema, - require: allocateNodeSchema, - }) -); - -const forcemergeSchema = schema.maybe( - schema.object({ - max_num_segments: schema.number(), - index_codec: schema.maybe(schema.literal('best_compression')), - }) -); - -const hotPhaseSchema = schema.object({ - min_age: minAgeSchema, - actions: schema.object({ - set_priority: setPrioritySchema, - unfollow: unfollowSchema, - rollover: schema.maybe( - schema.object({ - max_age: schema.maybe(schema.string()), - max_size: schema.maybe(schema.string()), - max_docs: schema.maybe(schema.number()), - }) - ), - forcemerge: forcemergeSchema, - }), -}); - -const warmPhaseSchema = schema.maybe( - schema.object({ - min_age: minAgeSchema, - actions: schema.object({ - migrate: migrateSchema, - set_priority: setPrioritySchema, - unfollow: unfollowSchema, - readonly: schema.maybe(schema.object({})), // Readonly has no options - allocate: allocateSchema, - shrink: schema.maybe( - schema.object({ - number_of_shards: schema.number(), - }) - ), - forcemerge: forcemergeSchema, - }), - }) -); - -const coldPhaseSchema = schema.maybe( - schema.object({ - min_age: minAgeSchema, - actions: schema.object({ - migrate: migrateSchema, - set_priority: setPrioritySchema, - unfollow: unfollowSchema, - allocate: allocateSchema, - freeze: schema.maybe(schema.object({})), // Freeze has no options - searchable_snapshot: schema.maybe( - schema.object({ - snapshot_repository: schema.string(), - }) - ), - }), - }) -); - -const deletePhaseSchema = schema.maybe( - schema.object({ - min_age: minAgeSchema, - actions: schema.object({ - wait_for_snapshot: schema.maybe( - schema.object({ - policy: schema.string(), - }) - ), - delete: schema.maybe( - schema.object({ - delete_searchable_snapshot: schema.maybe(schema.boolean()), - }) - ), - }), - }) -); - -// Per https://www.elastic.co/guide/en/elasticsearch/reference/current/_actions.html +/** + * We intentionally do not deeply validate the posted policy object to avoid erroring on valid ES + * policy configuration Kibana UI does not know or should not know about. For instance, the + * `force_merge_index` setting of the `searchable_snapshot` action. + * + * We only specify a rough structure based on https://www.elastic.co/guide/en/elasticsearch/reference/current/_actions.html. + */ const bodySchema = schema.object({ name: schema.string(), phases: schema.object({ - hot: hotPhaseSchema, - warm: warmPhaseSchema, - cold: coldPhaseSchema, - delete: deletePhaseSchema, + hot: schema.any(), + warm: schema.maybe(schema.any()), + cold: schema.maybe(schema.any()), + delete: schema.maybe(schema.any()), }), }); diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index 116345b35fdce..f1052672978d5 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 a3b4cb604231f..ef09dbfcb2674 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 ce78757676bcc..5476be50fee88 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 a211416472f48..6eef961a52e9b 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 cdd701271be2c..3066f85bbf3f9 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 9162af52052ee..2c23dc291405c 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 6c222bed7a83f..07dc69078e337 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 6b7e5ba8ea89d..93b4a4e3bea20 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 f5b31fb881167..67c6068dd4d91 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 935d65bfb6c08..fea9723aa700d 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 4cb523f128a8c..eec3f68ced5fc 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 0c96fc45de128..9c5eafc300abc 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 e0101493b27aa..55a4cb567fda1 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/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 95aeedbd857ca..6c2c01d944cd9 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -161,7 +161,7 @@ export function WorkspacePanel({ const expression = useMemo( () => { - if (!configurationValidationError) { + if (!configurationValidationError || configurationValidationError.length === 0) { try { return buildExpression({ visualization: activeVisualization, 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 9f9d7fef9c7b4..3a3258a79c59f 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 8139631daa971..76276f8b4c828 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 4a3ba971381fb..d18372246b0e6 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 3c9e19d30d38f..25cb34d19beb8 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/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index ac82caf9d5227..3d55494fd260c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -718,6 +718,25 @@ describe('IndexPattern Data Panel', () => { ]); }); + it('should announce filter in live region', () => { + const wrapper = mountWithIntl(); + act(() => { + wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').prop('onChange')!({ + target: { value: 'me' }, + } as ChangeEvent); + }); + + wrapper + .find('[data-test-subj="lnsIndexPatternEmptyFields"]') + .find('button') + .first() + .simulate('click'); + + expect(wrapper.find('[aria-live="polite"]').text()).toEqual( + '1 available field. 1 empty field. 0 meta fields.' + ); + }); + it('should filter down by type', () => { const wrapper = mountWithIntl(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index f2c7d7fc20926..ad5509dd88bc9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -18,10 +18,12 @@ import { EuiSpacer, EuiFilterGroup, EuiFilterButton, + EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { DataPublicPluginStart, EsQueryConfig, Query, Filter } from 'src/plugins/data/public'; +import { htmlIdGenerator } from '@elastic/eui'; import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; import { ChildDragDropProvider, DragContextState } from '../drag_drop'; import { @@ -222,6 +224,9 @@ const fieldFiltersLabel = i18n.translate('xpack.lens.indexPatterns.fieldFiltersL defaultMessage: 'Field filters', }); +const htmlId = htmlIdGenerator('datapanel'); +const fieldSearchDescriptionId = htmlId(); + export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ currentIndexPatternId, indexPatternRefs, @@ -489,6 +494,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ aria-label={i18n.translate('xpack.lens.indexPatterns.filterByNameAriaLabel', { defaultMessage: 'Search fields', })} + aria-describedby={fieldSearchDescriptionId} /> @@ -550,6 +556,19 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ + +
+ {i18n.translate('xpack.lens.indexPatterns.fieldSearchLiveRegion', { + defaultMessage: + '{availableFields} available {availableFields, plural, one {field} other {fields}}. {emptyFields} empty {emptyFields, plural, one {field} other {fields}}. {metaFields} meta {metaFields, plural, one {field} other {fields}}.', + values: { + availableFields: fieldGroups.AvailableFields.fields.length, + emptyFields: fieldGroups.EmptyFields.fields.length, + metaFields: fieldGroups.MetaFields.fields.length, + }, + })} +
+
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index e5c05a1cf8c7a..0a67c157bd837 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -6,7 +6,7 @@ import './dimension_editor.scss'; import _ from 'lodash'; -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiListGroup, @@ -46,10 +46,6 @@ export interface DimensionEditorProps extends IndexPatternDimensionEditorProps { const LabelInput = ({ value, onChange }: { value: string; onChange: (value: string) => void }) => { const [inputValue, setInputValue] = useState(value); - useEffect(() => { - setInputValue(value); - }, [value, setInputValue]); - const onChangeDebounced = useMemo(() => _.debounce(onChange, 256), [onChange]); const handleInputChange = (e: React.ChangeEvent) => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts index 793f3387e707d..5f7eddd807c93 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts @@ -37,12 +37,14 @@ export class IndexPatternDatasource { getIndexPatternDatasource, renameColumns, formatColumn, + counterRate, getTimeScaleFunction, getSuffixFormatter, } = await import('../async_services'); return core.getStartServices().then(([coreStart, { data }]) => { data.fieldFormats.register([getSuffixFormatter(data.fieldFormats.deserialize)]); expressions.registerFunction(getTimeScaleFunction(data)); + expressions.registerFunction(counterRate); expressions.registerFunction(renameColumns); expressions.registerFunction(formatColumn); return getIndexPatternDatasource({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 3cf9bdc3a92f1..c3247b251d88a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -661,19 +661,30 @@ describe('IndexPattern Data Source', () => { it('should skip columns that are being referenced', () => { publicAPI = indexPatternDatasource.getPublicAPI({ state: { + ...enrichBaseState(baseState), layers: { first: { indexPatternId: '1', columnOrder: ['col1', 'col2'], columns: { - // @ts-ignore this is too little information for a real column col1: { + label: 'Sum', dataType: 'number', - }, + isBucketed: false, + + operationType: 'sum', + sourceField: 'test', + params: {}, + } as IndexPatternColumn, col2: { - // @ts-expect-error update once we have a reference operation outside tests + label: 'Cumulative sum', + dataType: 'number', + isBucketed: false, + + operationType: 'cumulative_sum', references: ['col1'], - }, + params: {}, + } as IndexPatternColumn, }, }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 2c64431867df0..289b6bbe3f25b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -78,6 +78,7 @@ export function columnToOperation(column: IndexPatternColumn, uniqueLabel?: stri export * from './rename_columns'; export * from './format_column'; export * from './time_scale'; +export * from './counter_rate'; export * from './suffix_formatter'; export function getIndexPatternDatasource({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx new file mode 100644 index 0000000000000..d256b74696a4c --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types'; +import { IndexPatternLayer } from '../../../types'; +import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils'; +import { OperationDefinition } from '..'; + +const ofName = (name?: string) => { + return i18n.translate('xpack.lens.indexPattern.CounterRateOf', { + defaultMessage: 'Counter rate of {name}', + values: { + name: + name ?? + i18n.translate('xpack.lens.indexPattern.incompleteOperation', { + defaultMessage: '(incomplete)', + }), + }, + }); +}; + +export type CounterRateIndexPatternColumn = FormattedIndexPatternColumn & + ReferenceBasedIndexPatternColumn & { + operationType: 'counter_rate'; + }; + +export const counterRateOperation: OperationDefinition< + CounterRateIndexPatternColumn, + 'fullReference' +> = { + type: 'counter_rate', + priority: 1, + displayName: i18n.translate('xpack.lens.indexPattern.counterRate', { + defaultMessage: 'Counter rate', + }), + input: 'fullReference', + selectionStyle: 'field', + requiredReferences: [ + { + input: ['field'], + specificOperations: ['max'], + validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed, + }, + ], + getPossibleOperation: () => { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + getDefaultLabel: (column, indexPattern, columns) => { + return ofName(columns[column.references[0]]?.label); + }, + toExpression: (layer, columnId) => { + return dateBasedOperationToExpression(layer, columnId, 'lens_counter_rate'); + }, + buildColumn: ({ referenceIds, previousColumn, layer }) => { + const metric = layer.columns[referenceIds[0]]; + return { + label: ofName(metric?.label), + dataType: 'number', + operationType: 'counter_rate', + isBucketed: false, + scale: 'ratio', + references: referenceIds, + params: + previousColumn?.dataType === 'number' && + previousColumn.params && + 'format' in previousColumn.params && + previousColumn.params.format + ? { format: previousColumn.params.format } + : undefined, + }; + }, + isTransferable: (column, newIndexPattern) => { + return hasDateField(newIndexPattern); + }, + getErrorMessage: (layer: IndexPatternLayer) => { + return checkForDateHistogram( + layer, + i18n.translate('xpack.lens.indexPattern.counterRate', { + defaultMessage: 'Counter rate', + }) + ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx new file mode 100644 index 0000000000000..9244aaaf90ab7 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types'; +import { IndexPatternLayer } from '../../../types'; +import { checkForDateHistogram, dateBasedOperationToExpression } from './utils'; +import { OperationDefinition } from '..'; + +const ofName = (name?: string) => { + return i18n.translate('xpack.lens.indexPattern.cumulativeSumOf', { + defaultMessage: 'Cumulative sum rate of {name}', + values: { + name: + name ?? + i18n.translate('xpack.lens.indexPattern.incompleteOperation', { + defaultMessage: '(incomplete)', + }), + }, + }); +}; + +export type CumulativeSumIndexPatternColumn = FormattedIndexPatternColumn & + ReferenceBasedIndexPatternColumn & { + operationType: 'cumulative_sum'; + }; + +export const cumulativeSumOperation: OperationDefinition< + CumulativeSumIndexPatternColumn, + 'fullReference' +> = { + type: 'cumulative_sum', + priority: 1, + displayName: i18n.translate('xpack.lens.indexPattern.cumulativeSum', { + defaultMessage: 'Cumulative sum', + }), + input: 'fullReference', + selectionStyle: 'field', + requiredReferences: [ + { + input: ['field'], + specificOperations: ['count', 'sum'], + validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed, + }, + ], + getPossibleOperation: () => { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + getDefaultLabel: (column, indexPattern, columns) => { + return ofName(columns[column.references[0]]?.label); + }, + toExpression: (layer, columnId) => { + return dateBasedOperationToExpression(layer, columnId, 'cumulative_sum'); + }, + buildColumn: ({ referenceIds, previousColumn, layer }) => { + const metric = layer.columns[referenceIds[0]]; + return { + label: ofName(metric?.label), + dataType: 'number', + operationType: 'cumulative_sum', + isBucketed: false, + scale: 'ratio', + references: referenceIds, + params: + previousColumn?.dataType === 'number' && + previousColumn.params && + 'format' in previousColumn.params && + previousColumn.params.format + ? { format: previousColumn.params.format } + : undefined, + }; + }, + isTransferable: () => { + return true; + }, + getErrorMessage: (layer: IndexPatternLayer) => { + return checkForDateHistogram( + layer, + i18n.translate('xpack.lens.indexPattern.cumulativeSum', { + defaultMessage: 'Cumulative sum', + }) + ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx new file mode 100644 index 0000000000000..7398f7e07ea4e --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types'; +import { IndexPatternLayer } from '../../../types'; +import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils'; +import { OperationDefinition } from '..'; + +const ofName = (name?: string) => { + return i18n.translate('xpack.lens.indexPattern.derivativeOf', { + defaultMessage: 'Differences of {name}', + values: { + name: + name ?? + i18n.translate('xpack.lens.indexPattern.incompleteOperation', { + defaultMessage: '(incomplete)', + }), + }, + }); +}; + +export type DerivativeIndexPatternColumn = FormattedIndexPatternColumn & + ReferenceBasedIndexPatternColumn & { + operationType: 'derivative'; + }; + +export const derivativeOperation: OperationDefinition< + DerivativeIndexPatternColumn, + 'fullReference' +> = { + type: 'derivative', + priority: 1, + displayName: i18n.translate('xpack.lens.indexPattern.derivative', { + defaultMessage: 'Differences', + }), + input: 'fullReference', + selectionStyle: 'full', + requiredReferences: [ + { + input: ['field'], + validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed, + }, + ], + getPossibleOperation: () => { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + getDefaultLabel: (column, indexPattern, columns) => { + return ofName(columns[column.references[0]]?.label); + }, + toExpression: (layer, columnId) => { + return dateBasedOperationToExpression(layer, columnId, 'derivative'); + }, + buildColumn: ({ referenceIds, previousColumn, layer }) => { + const metric = layer.columns[referenceIds[0]]; + return { + label: ofName(metric?.label), + dataType: 'number', + operationType: 'derivative', + isBucketed: false, + scale: 'ratio', + references: referenceIds, + params: + previousColumn?.dataType === 'number' && + previousColumn.params && + 'format' in previousColumn.params && + previousColumn.params.format + ? { format: previousColumn.params.format } + : undefined, + }; + }, + isTransferable: (column, newIndexPattern) => { + return hasDateField(newIndexPattern); + }, + getErrorMessage: (layer: IndexPatternLayer) => { + return checkForDateHistogram( + layer, + i18n.translate('xpack.lens.indexPattern.derivative', { + defaultMessage: 'Differences', + }) + ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts new file mode 100644 index 0000000000000..30e87aef46a0d --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { counterRateOperation, CounterRateIndexPatternColumn } from './counter_rate'; +export { cumulativeSumOperation, CumulativeSumIndexPatternColumn } from './cumulative_sum'; +export { derivativeOperation, DerivativeIndexPatternColumn } from './derivative'; +export { movingAverageOperation, MovingAverageIndexPatternColumn } from './moving_average'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx new file mode 100644 index 0000000000000..795281d0fd994 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { useState } from 'react'; +import React from 'react'; +import { EuiFormRow } from '@elastic/eui'; +import { EuiFieldNumber } from '@elastic/eui'; +import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types'; +import { IndexPatternLayer } from '../../../types'; +import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils'; +import { updateColumnParam } from '../../layer_helpers'; +import { useDebounceWithOptions } from '../helpers'; +import type { OperationDefinition, ParamEditorProps } from '..'; + +const ofName = (name?: string) => { + return i18n.translate('xpack.lens.indexPattern.movingAverageOf', { + defaultMessage: 'Moving average of {name}', + values: { + name: + name ?? + i18n.translate('xpack.lens.indexPattern.incompleteOperation', { + defaultMessage: '(incomplete)', + }), + }, + }); +}; + +export type MovingAverageIndexPatternColumn = FormattedIndexPatternColumn & + ReferenceBasedIndexPatternColumn & { + operationType: 'moving_average'; + params: { + window: number; + }; + }; + +export const movingAverageOperation: OperationDefinition< + MovingAverageIndexPatternColumn, + 'fullReference' +> = { + type: 'moving_average', + priority: 1, + displayName: i18n.translate('xpack.lens.indexPattern.movingAverage', { + defaultMessage: 'Moving Average', + }), + input: 'fullReference', + selectionStyle: 'full', + requiredReferences: [ + { + input: ['field'], + validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed, + }, + ], + getPossibleOperation: () => { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + getDefaultLabel: (column, indexPattern, columns) => { + return ofName(columns[column.references[0]]?.label); + }, + toExpression: (layer, columnId) => { + return dateBasedOperationToExpression(layer, columnId, 'moving_average', { + window: [(layer.columns[columnId] as MovingAverageIndexPatternColumn).params.window], + }); + }, + buildColumn: ({ referenceIds, previousColumn, layer }) => { + const metric = layer.columns[referenceIds[0]]; + return { + label: ofName(metric?.label), + dataType: 'number', + operationType: 'moving_average', + isBucketed: false, + scale: 'ratio', + references: referenceIds, + params: + previousColumn?.dataType === 'number' && + previousColumn.params && + 'format' in previousColumn.params && + previousColumn.params.format + ? { format: previousColumn.params.format, window: 5 } + : { window: 5 }, + }; + }, + paramEditor: MovingAverageParamEditor, + isTransferable: (column, newIndexPattern) => { + return hasDateField(newIndexPattern); + }, + getErrorMessage: (layer: IndexPatternLayer) => { + return checkForDateHistogram( + layer, + i18n.translate('xpack.lens.indexPattern.movingAverage', { + defaultMessage: 'Moving Average', + }) + ); + }, +}; + +function MovingAverageParamEditor({ + state, + setState, + currentColumn, + layerId, +}: ParamEditorProps) { + const [inputValue, setInputValue] = useState(String(currentColumn.params.window)); + + useDebounceWithOptions( + () => { + if (inputValue === '') { + return; + } + const inputNumber = Number(inputValue); + setState( + updateColumnParam({ + state, + layerId, + currentColumn, + paramName: 'window', + value: inputNumber, + }) + ); + }, + { skipFirstRender: true }, + 256, + [inputValue] + ); + return ( + + ) => setInputValue(e.target.value)} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts new file mode 100644 index 0000000000000..c64a292280603 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunctionAST } from '@kbn/interpreter/common'; +import { IndexPattern, IndexPatternLayer } from '../../../types'; +import { ReferenceBasedIndexPatternColumn } from '../column_types'; + +/** + * Checks whether the current layer includes a date histogram and returns an error otherwise + */ +export function checkForDateHistogram(layer: IndexPatternLayer, name: string) { + const buckets = layer.columnOrder.filter((colId) => layer.columns[colId].isBucketed); + const hasDateHistogram = buckets.some( + (colId) => layer.columns[colId].operationType === 'date_histogram' + ); + if (hasDateHistogram) { + return undefined; + } + return [ + i18n.translate('xpack.lens.indexPattern.calculations.dateHistogramErrorMessage', { + defaultMessage: + '{name} requires a date histogram to work. Choose a different function or add a date histogram.', + values: { + name, + }, + }), + ]; +} + +export function hasDateField(indexPattern: IndexPattern) { + return indexPattern.fields.some((field) => field.type === 'date'); +} + +/** + * Creates an expression ast for a date based operation (cumulative sum, derivative, moving average, counter rate) + */ +export function dateBasedOperationToExpression( + layer: IndexPatternLayer, + columnId: string, + functionName: string, + additionalArgs: Record = {} +): ExpressionFunctionAST[] { + const currentColumn = (layer.columns[columnId] as unknown) as ReferenceBasedIndexPatternColumn; + const buckets = layer.columnOrder.filter((colId) => layer.columns[colId].isBucketed); + const dateColumnIndex = buckets.findIndex( + (colId) => layer.columns[colId].operationType === 'date_histogram' + )!; + buckets.splice(dateColumnIndex, 1); + + return [ + { + type: 'function', + function: functionName, + arguments: { + by: buckets, + inputColumnId: [currentColumn.references[0]], + outputColumnId: [columnId], + outputColumnName: [currentColumn.label], + ...additionalArgs, + }, + }, + ]; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts index 13bddc0c2ec26..aef9bb7731d4c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts @@ -16,7 +16,7 @@ export interface BaseIndexPatternColumn extends Operation { // export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn { export type FormattedIndexPatternColumn = BaseIndexPatternColumn & { params?: { - format: { + format?: { id: string; params?: { decimals: number; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx index b9d9d6306b9ae..ca84c072be5ce 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx @@ -110,10 +110,6 @@ export const QueryInput = ({ }) => { const [inputValue, setInputValue] = useState(value); - React.useEffect(() => { - setInputValue(value); - }, [value, setInputValue]); - useDebounce(() => onChange(inputValue), 256, [inputValue]); const handleInputChange = (input: Query) => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 0e7e125944e71..392377234d76d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -23,6 +23,16 @@ import { MedianIndexPatternColumn, } from './metrics'; import { dateHistogramOperation, DateHistogramIndexPatternColumn } from './date_histogram'; +import { + cumulativeSumOperation, + CumulativeSumIndexPatternColumn, + counterRateOperation, + CounterRateIndexPatternColumn, + derivativeOperation, + DerivativeIndexPatternColumn, + movingAverageOperation, + MovingAverageIndexPatternColumn, +} from './calculations'; import { countOperation, CountIndexPatternColumn } from './count'; import { StateSetter, OperationMetadata } from '../../../types'; import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types'; @@ -52,7 +62,11 @@ export type IndexPatternColumn = | CardinalityIndexPatternColumn | SumIndexPatternColumn | MedianIndexPatternColumn - | CountIndexPatternColumn; + | CountIndexPatternColumn + | CumulativeSumIndexPatternColumn + | CounterRateIndexPatternColumn + | DerivativeIndexPatternColumn + | MovingAverageIndexPatternColumn; export type FieldBasedIndexPatternColumn = Extract; @@ -73,6 +87,10 @@ const internalOperationDefinitions = [ medianOperation, countOperation, rangeOperation, + cumulativeSumOperation, + counterRateOperation, + derivativeOperation, + movingAverageOperation, ]; export { termsOperation } from './terms'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx index f0ee30bb4331b..ddcb5633b376f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import { EuiFieldText, keys } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -28,10 +28,6 @@ export const LabelInput = ({ }) => { const [inputValue, setInputValue] = useState(value); - useEffect(() => { - setInputValue(value); - }, [value, setInputValue]); - useDebounce(() => onChange(inputValue), 256, [inputValue]); const handleInputChange = (e: React.ChangeEvent) => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 1495a876a2c8e..58a066c81a1a7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -424,7 +424,6 @@ export function deleteColumn({ }; } - // @ts-expect-error this fails statically because there are no references added const extraDeletions: string[] = 'references' in column ? column.references : []; const hypotheticalColumns = { ...layer.columns }; @@ -452,11 +451,9 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] { ); // If a reference has another reference as input, put it last in sort order referenceBased.sort(([idA, a], [idB, b]) => { - // @ts-expect-error not statically analyzed if ('references' in a && a.references.includes(idB)) { return 1; } - // @ts-expect-error not statically analyzed if ('references' in b && b.references.includes(idA)) { return -1; } @@ -517,14 +514,12 @@ export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined } if ('references' in column) { - // @ts-expect-error references are not statically analyzed yet column.references.forEach((referenceId, index) => { if (!layer.columns[referenceId]) { errors.push( i18n.translate('xpack.lens.indexPattern.missingReferenceError', { defaultMessage: 'Dimension {dimensionLabel} is incomplete', values: { - // @ts-expect-error references are not statically analyzed yet dimensionLabel: column.label, }, }) @@ -544,7 +539,6 @@ export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined i18n.translate('xpack.lens.indexPattern.invalidReferenceConfiguration', { defaultMessage: 'Dimension {dimensionLabel} does not have a valid configuration', values: { - // @ts-expect-error references are not statically analyzed yet dimensionLabel: column.label, }, }) @@ -560,10 +554,7 @@ export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined export function isReferenced(layer: IndexPatternLayer, columnId: string): boolean { const allReferences = Object.values(layer.columns).flatMap((col) => - 'references' in col - ? // @ts-expect-error not statically analyzed - col.references - : [] + 'references' in col ? col.references : [] ); return allReferences.includes(columnId); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index d6f5b10cf64e1..63d0fd3d4e5c5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -247,6 +247,22 @@ describe('getOperationTypesForField', () => { "operationType": "sum", "type": "field", }, + Object { + "operationType": "cumulative_sum", + "type": "fullReference", + }, + Object { + "operationType": "counter_rate", + "type": "fullReference", + }, + Object { + "operationType": "derivative", + "type": "fullReference", + }, + Object { + "operationType": "moving_average", + "type": "fullReference", + }, Object { "field": "bytes", "operationType": "min", diff --git a/x-pack/plugins/lens/public/pie_visualization/expression.tsx b/x-pack/plugins/lens/public/pie_visualization/expression.tsx index 3b5226eaa8e1f..5f18ef7c7f637 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 c44179ccd8dfc..458b1a75c4c17 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 39743a355fd78..56ecf57f2dff7 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); @@ -65,6 +68,7 @@ export function PieComponent( } = props.args; const chartTheme = chartsThemeService.useChartsTheme(); const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); + const isDarkMode = chartsThemeService.useDarkMode(); if (!hideLabels) { firstTable.columns.forEach((column) => { @@ -125,7 +129,9 @@ export function PieComponent( if (shape === 'treemap') { // Only highlight the innermost color of the treemap, as it accurately represents area if (layerIndex < bucketColumns.length - 1) { - return 'rgba(0,0,0,0)'; + // Mind the difference here: the contrast computation for the text ignores the alpha/opacity + // therefore change it for dask mode + return isDarkMode ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0)'; } // only use the top level series layer for coloring if (seriesLayers.length > 1) { @@ -228,6 +234,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: { + ...chartTheme.background, color: undefined, // removes background for embeddables }, }} 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 3097c40663132..c0393a7e48865 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 497fb2e7de840..5eacb118b27df 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 c19e7970b45ae..02b7900a4c003 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 225fedb987c76..2f40f21455310 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 a4b5d741c80f1..0e2b47410c3f9 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 54ae3bb759d2c..790416a6c920d 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 7bbb039577306..a308a0c293029 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 af3ec42ab4ec5..b21a821ec6a72 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 33f28cfc97291..6ae7aa25fc3bf 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 987e7bc93c2f6..0000000000000 --- 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 75bf59d9d6404..32bd767aa94d8 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 465ffbda27303..0000000000000 --- 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 b4c0098bb1338..e4ae0aed15729 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 8ef50a1cb7a1c..328594f00a1f0 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 98b58def905eb..c2cd46f26f990 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 efdede82a7449..5c45b33a7c31b 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 f6282be26b40c..9a1b31852d39c 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 d708cd56b78df..91020eee26602 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 b5a78ee746efe..1232c94d7dee1 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 9f4d402ec1759..d6c9ad758e8c6 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/_index.scss b/x-pack/plugins/ml/public/application/_index.scss index 45b14543946c7..7512c180970ad 100644 --- a/x-pack/plugins/ml/public/application/_index.scss +++ b/x-pack/plugins/ml/public/application/_index.scss @@ -23,7 +23,6 @@ @import 'components/controls/index'; @import 'components/entity_cell/index'; @import 'components/field_title_bar/index'; - @import 'components/field_type_icon/index'; @import 'components/influencers_list/index'; @import 'components/items_grid/index'; @import 'components/job_selector/index'; diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 9dc0814e3a3e6..d3a055f957c3a 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -120,8 +120,6 @@ export const renderApp = ( urlGenerators: deps.share.urlGenerators, }); - deps.kibanaLegacy.loadFontAwesome(); - appMountParams.onAppLeave((actions) => actions.default()); const mlLicense = setLicenseCache(deps.licensing, [ diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js index 0a2c67a3b0dcb..ebc782fe4625b 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js @@ -25,8 +25,9 @@ import { mlTableService } from '../../services/table_service'; import { RuleEditorFlyout } from '../rule_editor'; import { ml } from '../../services/ml_api_service'; import { INFLUENCERS_LIMIT, ANOMALIES_TABLE_TABS, MAX_CHARS } from './anomalies_table_constants'; +import { usePageUrlState } from '../../util/url_state'; -class AnomaliesTable extends Component { +export class AnomaliesTableInternal extends Component { constructor(props) { super(props); @@ -145,8 +146,20 @@ class AnomaliesTable extends Component { }); }; + onTableChange = ({ page, sort }) => { + const { tableState, updateTableState } = this.props; + const result = { + pageIndex: page && page.index !== undefined ? page.index : tableState.pageIndex, + pageSize: page && page.size !== undefined ? page.size : tableState.pageSize, + sortField: sort && sort.field !== undefined ? sort.field : tableState.sortField, + sortDirection: + sort && sort.direction !== undefined ? sort.direction : tableState.sortDirection, + }; + updateTableState(result); + }; + render() { - const { bounds, tableData, filter, influencerFilter } = this.props; + const { bounds, tableData, filter, influencerFilter, tableState } = this.props; if ( tableData === undefined || @@ -186,8 +199,8 @@ class AnomaliesTable extends Component { const sorting = { sort: { - field: 'severity', - direction: 'desc', + field: tableState.sortField, + direction: tableState.sortDirection, }, }; @@ -199,8 +212,15 @@ class AnomaliesTable extends Component { }; }; + const pagination = { + pageIndex: tableState.pageIndex, + pageSize: tableState.pageSize, + totalItemCount: tableData.anomalies.length, + pageSizeOptions: [10, 25, 100], + }; + return ( - + <> - + ); } } -AnomaliesTable.propTypes = { + +export const getDefaultAnomaliesTableState = () => ({ + pageIndex: 0, + pageSize: 25, + sortField: 'severity', + sortDirection: 'desc', +}); + +export const AnomaliesTable = (props) => { + const [tableState, updateTableState] = usePageUrlState( + 'mlAnomaliesTable', + getDefaultAnomaliesTableState() + ); + return ( + + ); +}; + +AnomaliesTableInternal.propTypes = { bounds: PropTypes.object.isRequired, tableData: PropTypes.object, filter: PropTypes.func, influencerFilter: PropTypes.func, + tableState: PropTypes.object.isRequired, + updateTableState: PropTypes.func.isRequired, }; - -export { AnomaliesTable }; 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 70538d4dc3a91..d0a3bd0652690 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 b8333e72c9ffb..3e48dcba84be2 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/field_title_bar/_field_title_bar.scss b/x-pack/plugins/ml/public/application/components/field_title_bar/_field_title_bar.scss index 75118266d45db..77d95653638ac 100644 --- a/x-pack/plugins/ml/public/application/components/field_title_bar/_field_title_bar.scss +++ b/x-pack/plugins/ml/public/application/components/field_title_bar/_field_title_bar.scss @@ -12,7 +12,7 @@ .field-type-icon { vertical-align: middle; - padding-right: $euiSizeXS; + margin-bottom: -$euiSizeXS; display: inline-block; } diff --git a/x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.test.js b/x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.test.tsx similarity index 63% rename from x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.test.js rename to x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.test.tsx index b467b6bfa3654..aed66550554d3 100644 --- a/x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.test.js +++ b/x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.test.tsx @@ -5,26 +5,22 @@ */ import { mountWithIntl } from '@kbn/test/jest'; + import React from 'react'; import { FieldTitleBar } from './field_title_bar'; - -// helper to let PropTypes throw errors instead of just doing console.error() -const error = console.error; -console.error = (warning, ...args) => { - if (/(Invalid prop|Failed prop type)/gi.test(warning)) { - throw new Error(warning); - } - error.apply(console, [warning, ...args]); -}; +import { ML_JOB_FIELD_TYPES } from '../../../../common/constants/field_types'; describe('FieldTitleBar', () => { - test(`throws an error because card is a required prop`, () => { - expect(() => ).toThrow(); - }); - test(`card prop is an empty object`, () => { - const props = { card: {} }; + const props = { + card: { + type: ML_JOB_FIELD_TYPES.NUMBER, + existsInDocs: true, + loading: false, + aggregatable: true, + }, + }; const wrapper = mountWithIntl(); @@ -36,29 +32,43 @@ describe('FieldTitleBar', () => { }); test(`card.isUnsupportedType is true`, () => { - const testFieldName = 'foo'; - const props = { card: { fieldName: testFieldName, isUnsupportedType: true } }; + const props = { + card: { + type: ML_JOB_FIELD_TYPES.UNKNOWN, + fieldName: 'foo', + existsInDocs: true, + loading: false, + aggregatable: true, + isUnsupportedType: true, + }, + }; const wrapper = mountWithIntl(); const fieldName = wrapper.find({ className: 'field-name' }).text(); - expect(fieldName).toEqual(testFieldName); + expect(fieldName).toEqual(props.card.fieldName); const hasClassName = wrapper.find('EuiText').hasClass('type-other'); expect(hasClassName).toBeTruthy(); }); test(`card.fieldName and card.type is set`, () => { - const testFieldName = 'foo'; - const testType = 'bar'; - const props = { card: { fieldName: testFieldName, type: testType } }; + const props = { + card: { + type: ML_JOB_FIELD_TYPES.KEYWORD, + fieldName: 'bar', + existsInDocs: true, + loading: false, + aggregatable: true, + }, + }; const wrapper = mountWithIntl(); const fieldName = wrapper.find({ className: 'field-name' }).text(); - expect(fieldName).toEqual(testFieldName); + expect(fieldName).toEqual(props.card.fieldName); - const hasClassName = wrapper.find('EuiText').hasClass(testType); + const hasClassName = wrapper.find('EuiText').hasClass(props.card.type); expect(hasClassName).toBeTruthy(); }); @@ -66,11 +76,19 @@ describe('FieldTitleBar', () => { // Use fake timers so we don't have to wait for the EuiToolTip timeout jest.useFakeTimers(); - const props = { card: { fieldName: 'foo', type: 'bar' } }; + const props = { + card: { + type: ML_JOB_FIELD_TYPES.KEYWORD, + fieldName: 'bar', + existsInDocs: true, + loading: false, + aggregatable: true, + }, + }; const wrapper = mountWithIntl(); const container = wrapper.find({ className: 'field-name' }); - expect(wrapper.find('EuiToolTip').children()).toHaveLength(1); + expect(wrapper.find('EuiToolTip').children()).toHaveLength(2); container.simulate('mouseover'); @@ -78,7 +96,7 @@ describe('FieldTitleBar', () => { jest.runAllTimers(); wrapper.update(); - expect(wrapper.find('EuiToolTip').children()).toHaveLength(2); + expect(wrapper.find('EuiToolTip').children()).toHaveLength(3); container.simulate('mouseout'); @@ -86,7 +104,7 @@ describe('FieldTitleBar', () => { jest.runAllTimers(); wrapper.update(); - expect(wrapper.find('EuiToolTip').children()).toHaveLength(1); + expect(wrapper.find('EuiToolTip').children()).toHaveLength(2); // Clearing all mocks will also reset fake timers. jest.clearAllMocks(); diff --git a/x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.js b/x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.tsx similarity index 68% rename from x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.js rename to x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.tsx index 28aa15c2cfab0..0e98a23637f03 100644 --- a/x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.js +++ b/x-pack/plugins/ml/public/application/components/field_title_bar/field_title_bar.tsx @@ -4,21 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { FC } from 'react'; import { EuiText, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + import { FieldTypeIcon } from '../field_type_icon'; +import { FieldVisConfig } from '../../datavisualizer/index_based/common'; import { getMLJobTypeAriaLabel } from '../../util/field_types_utils'; -import { i18n } from '@kbn/i18n'; -export function FieldTitleBar({ card }) { - // don't render and fail gracefully if card prop isn't set - if (typeof card !== 'object' || card === null) { - return null; - } +interface Props { + card: FieldVisConfig; +} +export const FieldTitleBar: FC = ({ card }) => { const fieldName = card.fieldName || i18n.translate('xpack.ml.fieldTitleBar.documentCountLabel', { @@ -37,20 +37,23 @@ export function FieldTitleBar({ card }) { } if (card.isUnsupportedType !== true) { - cardTitleAriaLabel.unshift(getMLJobTypeAriaLabel(card.type)); + // All the supported field types have aria labels. + cardTitleAriaLabel.unshift(getMLJobTypeAriaLabel(card.type)!); } return ( - + -
+
{fieldName}
); -} -FieldTitleBar.propTypes = { - card: PropTypes.object.isRequired, }; diff --git a/x-pack/plugins/ml/public/application/components/field_title_bar/index.js b/x-pack/plugins/ml/public/application/components/field_title_bar/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/field_title_bar/index.js rename to x-pack/plugins/ml/public/application/components/field_title_bar/index.ts diff --git a/x-pack/plugins/ml/public/application/components/field_type_icon/__snapshots__/field_type_icon.test.js.snap b/x-pack/plugins/ml/public/application/components/field_type_icon/__snapshots__/field_type_icon.test.js.snap deleted file mode 100644 index 3952d0b090a7d..0000000000000 --- a/x-pack/plugins/ml/public/application/components/field_type_icon/__snapshots__/field_type_icon.test.js.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FieldTypeIcon render component when type matches a field type 1`] = ` - -`; - -exports[`FieldTypeIcon update component 1`] = ` - -`; diff --git a/x-pack/plugins/ml/public/application/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap b/x-pack/plugins/ml/public/application/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap new file mode 100644 index 0000000000000..769ebdeba9955 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FieldTypeIcon render component when type matches a field type 1`] = ` + + + +`; diff --git a/x-pack/plugins/ml/public/application/components/field_type_icon/_field_type_icon.scss b/x-pack/plugins/ml/public/application/components/field_type_icon/_field_type_icon.scss deleted file mode 100644 index 741974c56987e..0000000000000 --- a/x-pack/plugins/ml/public/application/components/field_type_icon/_field_type_icon.scss +++ /dev/null @@ -1,22 +0,0 @@ -$icon-size: 20px; - -.field-type-icon-container { - display: inline-block !important; - vertical-align: middle; - border: 1px solid; - border-radius: 4px; - width: $icon-size; - height: $icon-size; - line-height: $icon-size; - text-align: center; - position: relative; - - .field-type-icon { - padding: 0; - display: inline-block !important; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } -} diff --git a/x-pack/plugins/ml/public/application/components/field_type_icon/_index.scss b/x-pack/plugins/ml/public/application/components/field_type_icon/_index.scss deleted file mode 100644 index afd1cb353edb4..0000000000000 --- a/x-pack/plugins/ml/public/application/components/field_type_icon/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'field_type_icon'; \ No newline at end of file diff --git a/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.test.js b/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.test.tsx similarity index 63% rename from x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.test.js rename to x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.test.tsx index d4200c2f8366b..667cca99389cf 100644 --- a/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.test.js +++ b/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.test.tsx @@ -11,18 +11,10 @@ import { FieldTypeIcon } from './field_type_icon'; import { ML_JOB_FIELD_TYPES } from '../../../../common/constants/field_types'; describe('FieldTypeIcon', () => { - test(`don't render component when type is undefined`, () => { - const typeIconComponent = shallow(); - expect(typeIconComponent.isEmptyRender()).toBeTruthy(); - }); - - test(`don't render component when type doesn't match a field type`, () => { - const typeIconComponent = shallow(); - expect(typeIconComponent.isEmptyRender()).toBeTruthy(); - }); - test(`render component when type matches a field type`, () => { - const typeIconComponent = shallow(); + const typeIconComponent = shallow( + + ); expect(typeIconComponent).toMatchSnapshot(); }); @@ -31,9 +23,9 @@ describe('FieldTypeIcon', () => { jest.useFakeTimers(); const typeIconComponent = mount( - + ); - const container = typeIconComponent.find({ className: 'field-type-icon-container' }); + const container = typeIconComponent.find({ 'data-test-subj': 'mlFieldTypeIcon' }); expect(typeIconComponent.find('EuiToolTip').children()).toHaveLength(1); @@ -56,11 +48,4 @@ describe('FieldTypeIcon', () => { // Clearing all mocks will also reset fake timers. jest.clearAllMocks(); }); - - test(`update component`, () => { - const typeIconComponent = shallow(); - expect(typeIconComponent.isEmptyRender()).toBeTruthy(); - typeIconComponent.setProps({ type: ML_JOB_FIELD_TYPES.IP }); - expect(typeIconComponent).toMatchSnapshot(); - }); }); diff --git a/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.js b/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.tsx similarity index 54% rename from x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.js rename to x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.tsx index 1853c3d629c3e..7f736b65d494c 100644 --- a/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.js +++ b/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.tsx @@ -4,63 +4,80 @@ * you may not use this file except in compliance with the Elastic License. */ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { FC } from 'react'; -import { EuiToolTip } from '@elastic/eui'; +import { EuiToken, EuiToolTip } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; import { getMLJobTypeAriaLabel } from '../../util/field_types_utils'; import { ML_JOB_FIELD_TYPES } from '../../../../common/constants/field_types'; -import { i18n } from '@kbn/i18n'; -export const FieldTypeIcon = ({ tooltipEnabled = false, type, needsAria = true }) => { - const ariaLabel = getMLJobTypeAriaLabel(type); +interface FieldTypeIconProps { + tooltipEnabled: boolean; + type: ML_JOB_FIELD_TYPES; + fieldName?: string; + needsAria: boolean; +} - if (ariaLabel === null) { - // All ml job field types should have associated aria labels. - // Once it is missing, it means that the passed *type* is not a valid field type. - // if type doesn't match one of ML_JOB_FIELD_TYPES - // don't render the component at all - return null; - } +interface FieldTypeIconContainerProps { + ariaLabel: string | null; + iconType: string; + color: string; + needsAria: boolean; + [key: string]: any; +} - const iconClass = ['field-type-icon']; - let iconChar = ''; +export const FieldTypeIcon: FC = ({ + tooltipEnabled = false, + type, + fieldName, + needsAria = true, +}) => { + const ariaLabel = getMLJobTypeAriaLabel(type); + + let iconType = 'questionInCircle'; + let color = 'euiColorVis6'; switch (type) { - // icon class names + // Set icon types and colors case ML_JOB_FIELD_TYPES.BOOLEAN: - iconClass.push('kuiIcon', 'fa-adjust'); + iconType = 'tokenBoolean'; + color = 'euiColorVis5'; break; case ML_JOB_FIELD_TYPES.DATE: - iconClass.push('kuiIcon', 'fa-clock-o'); + iconType = 'tokenDate'; + color = 'euiColorVis7'; break; case ML_JOB_FIELD_TYPES.GEO_POINT: - iconClass.push('kuiIcon', 'fa-globe'); + iconType = 'tokenGeo'; + color = 'euiColorVis8'; break; case ML_JOB_FIELD_TYPES.TEXT: - iconClass.push('kuiIcon', 'fa-file-text-o'); + iconType = 'document'; + color = 'euiColorVis9'; break; case ML_JOB_FIELD_TYPES.IP: - iconClass.push('kuiIcon', 'fa-laptop'); + iconType = 'tokenIP'; + color = 'euiColorVis3'; break; - - // icon chars case ML_JOB_FIELD_TYPES.KEYWORD: - iconChar = 't'; + iconType = 'tokenText'; + color = 'euiColorVis0'; break; case ML_JOB_FIELD_TYPES.NUMBER: - iconChar = '#'; + iconType = 'tokenNumber'; + color = fieldName !== undefined ? 'euiColorVis1' : 'euiColorVis2'; break; case ML_JOB_FIELD_TYPES.UNKNOWN: - iconChar = '?'; + // Use defaults break; } const containerProps = { ariaLabel, - className: iconClass.join(' '), - iconChar, + iconType, + color, needsAria, }; @@ -84,28 +101,27 @@ export const FieldTypeIcon = ({ tooltipEnabled = false, type, needsAria = true } return ; }; -FieldTypeIcon.propTypes = { - tooltipEnabled: PropTypes.bool, - type: PropTypes.string, -}; - // If the tooltip is used, it will apply its events to its first inner child. // To pass on its properties we apply `rest` to the outer `span` element. -function FieldTypeIconContainer({ ariaLabel, className, iconChar, needsAria, ...rest }) { - const wrapperProps = { className }; +const FieldTypeIconContainer: FC = ({ + ariaLabel, + iconType, + color, + needsAria, + ...rest +}) => { + const wrapperProps: { className: string; 'aria-label'?: string } = { + className: 'field-type-icon', + }; if (needsAria && ariaLabel) { wrapperProps['aria-label'] = ariaLabel; } return ( - - {iconChar === '' ? ( - - ) : ( - - - - )} + + + + ); -} +}; diff --git a/x-pack/plugins/ml/public/application/components/field_type_icon/index.js b/x-pack/plugins/ml/public/application/components/field_type_icon/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/field_type_icon/index.js rename to x-pack/plugins/ml/public/application/components/field_type_icon/index.ts 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 7d1a616d57114..a4dc78ea53a77 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 96d41be03a142..6e9ac4d0a1e1c 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/datavisualizer/file_based/components/fields_stats/_field_stats_card.scss b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/_field_stats_card.scss index 48aab16d85be6..f6851fcb8eca4 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/_field_stats_card.scss +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/_field_stats_card.scss @@ -15,83 +15,47 @@ .boolean { color: $euiColorVis5; border-color: $euiColorVis5; - - .field-type-icon-container { - background-color: rgba($euiColorVis5, 0.2); - } } .date { color: $euiColorVis7; border-color: $euiColorVis7; - - .field-type-icon-container { - background-color: rgba($euiColorVis7, 0.2); - } } .document_count { color: $euiColorVis2; border-color: $euiColorVis2; - - .field-type-icon-container { - background-color: rgba($euiColorVis2, 0.2); - } } .geo_point { color: $euiColorVis8; border-color: $euiColorVis8; - - .field-type-icon-container { - background-color: rgba($euiColorVis8, 0.2); - } } .ip { color: $euiColorVis3; border-color: $euiColorVis3; - - .field-type-icon-container { - background-color: rgba($euiColorVis3, 0.2); - } } .keyword { color: $euiColorVis0; border-color: $euiColorVis0; - - .field-type-icon-container { - background-color: rgba($euiColorVis0, 0.2); - } } .number { color: $euiColorVis1; border-color: $euiColorVis1; - - .field-type-icon-container { - background-color: rgba($euiColorVis1, 0.2); - } } .text { color: $euiColorVis9; border-color: $euiColorVis9; - - .field-type-icon-container { - background-color: rgba($euiColorVis9, 0.2); - } } .type-other, .unknown { color: $euiColorVis6; border-color: $euiColorVis6; - - .field-type-icon-container { - background-color: rgba($euiColorVis6, 0.2); - } } // Use euiPanel styling diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/field_stats_card.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/field_stats_card.js index e68d73fc6acfa..2e9efa43f36bc 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/field_stats_card.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/field_stats_card.js @@ -5,7 +5,15 @@ */ import React from 'react'; -import { EuiSpacer, EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText, EuiProgress } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + EuiProgress, + EuiSpacer, + EuiText, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { FieldTypeIcon } from '../../../../components/field_type_icon'; @@ -28,7 +36,7 @@ export function FieldStatsCard({ field }) {
- +
{field.name}
@@ -38,29 +46,45 @@ export function FieldStatsCard({ field }) { {field.count > 0 && (
-
-